Browse Source

Initial commit, converting laundry app to site

Tyler Hallada 9 years ago
commit
2e2ee19a6b

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
1
+*~
2
+*.log
3
+*.pot
4
+*.py[cod]
5
+*.so
6
+*.db
7
+*.DS_Store
8
+*.swp
9
+*.sw[on]
10
+venv
11
+apache
12
+old
13
+*db
14
+laundry/secrets.py
15
+static
16
+*.svg

File diff suppressed because it is too large
+ 1 - 0
laundry.json


+ 0 - 0
laundry/__init__.py


+ 166 - 0
laundry/settings.py

@@ -0,0 +1,166 @@
1
+# Django settings for laundry project.
2
+import os
3
+import secrets
4
+
5
+DEBUG = True
6
+TEMPLATE_DEBUG = DEBUG
7
+
8
+ADMINS = (
9
+    ('thallada', 'thallada@mit.edu'),
10
+    ('tyler', 'tyler@hallada.net'),
11
+)
12
+
13
+MANAGERS = ADMINS
14
+
15
+PROJECT_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), '..')
16
+
17
+DATABASES = {
18
+    'default': {
19
+        'ENGINE': 'django.db.backends.mysql',
20
+        'NAME': secrets.DATABASE_NAME,
21
+        'USER': secrets.DATABASE_USER,
22
+        'PASSWORD': secrets.DATABASE_PASSWORD,
23
+        'HOST': secrets.DATABASE_HOST,
24
+        'PORT': '',                      # Set to empty string for default. Not used with sqlite3.
25
+    }
26
+}
27
+
28
+# Hosts/domain names that are valid for this site; required if DEBUG is False
29
+# See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts
30
+ALLOWED_HOSTS = []
31
+
32
+# Local time zone for this installation. Choices can be found here:
33
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
34
+# although not all choices may be available on all operating systems.
35
+# In a Windows environment this must be set to your system time zone.
36
+TIME_ZONE = 'America/New_York'
37
+
38
+# Language code for this installation. All choices can be found here:
39
+# http://www.i18nguy.com/unicode/language-identifiers.html
40
+LANGUAGE_CODE = 'en-us'
41
+
42
+SITE_ID = 1
43
+
44
+# If you set this to False, Django will make some optimizations so as not
45
+# to load the internationalization machinery.
46
+USE_I18N = True
47
+
48
+# If you set this to False, Django will not format dates, numbers and
49
+# calendars according to the current locale.
50
+USE_L10N = True
51
+
52
+# If you set this to False, Django will not use timezone-aware datetimes.
53
+USE_TZ = True
54
+
55
+# Absolute filesystem path to the directory that will hold user-uploaded files.
56
+# Example: "/var/www/example.com/media/"
57
+MEDIA_ROOT = ''
58
+
59
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
60
+# trailing slash.
61
+# Examples: "http://example.com/media/", "http://media.example.com/"
62
+MEDIA_URL = ''
63
+
64
+# Absolute path to the directory static files should be collected to.
65
+# Don't put anything in this directory yourself; store your static files
66
+# in apps' "static/" subdirectories and in STATICFILES_DIRS.
67
+# Example: "/var/www/example.com/static/"
68
+STATIC_ROOT = os.path.join(PROJECT_PATH, 'static')
69
+
70
+# URL prefix for static files.
71
+# Example: "http://example.com/static/", "http://static.example.com/"
72
+STATIC_URL = '/static/'
73
+
74
+# Additional locations of static files
75
+STATICFILES_DIRS = (
76
+    # Put strings here, like "/home/html/static" or "C:/www/django/static".
77
+    # Always use forward slashes, even on Windows.
78
+    # Don't forget to use absolute paths, not relative paths.
79
+    os.path.join(PROJECT_PATH, 'laundry_app', 'static'),
80
+)
81
+
82
+# List of finder classes that know how to find static files in
83
+# various locations.
84
+STATICFILES_FINDERS = (
85
+    'django.contrib.staticfiles.finders.FileSystemFinder',
86
+    'django.contrib.staticfiles.finders.AppDirectoriesFinder',
87
+#    'django.contrib.staticfiles.finders.DefaultStorageFinder',
88
+)
89
+
90
+# Make this unique, and don't share it with anybody.
91
+SECRET_KEY = secrets.SECRET_KEY
92
+
93
+# List of callables that know how to import templates from various sources.
94
+TEMPLATE_LOADERS = (
95
+    'django.template.loaders.filesystem.Loader',
96
+    'django.template.loaders.app_directories.Loader',
97
+#     'django.template.loaders.eggs.Loader',
98
+)
99
+
100
+MIDDLEWARE_CLASSES = (
101
+    'django.middleware.common.CommonMiddleware',
102
+    'django.contrib.sessions.middleware.SessionMiddleware',
103
+    'django.middleware.csrf.CsrfViewMiddleware',
104
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
105
+    'django.contrib.messages.middleware.MessageMiddleware',
106
+    # Uncomment the next line for simple clickjacking protection:
107
+    # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
108
+)
109
+
110
+ROOT_URLCONF = 'laundry.urls'
111
+
112
+# Python dotted path to the WSGI application used by Django's runserver.
113
+WSGI_APPLICATION = 'laundry.wsgi.application'
114
+
115
+TEMPLATE_DIRS = (
116
+    # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
117
+    # Always use forward slashes, even on Windows.
118
+    # Don't forget to use absolute paths, not relative paths.
119
+    os.path.join(PROJECT_PATH, 'templates'),
120
+)
121
+
122
+INSTALLED_APPS = (
123
+    'django.contrib.auth',
124
+    'django.contrib.contenttypes',
125
+    'django.contrib.sessions',
126
+    'django.contrib.sites',
127
+    'django.contrib.messages',
128
+    'django.contrib.staticfiles',
129
+    # Uncomment the next line to enable the admin:
130
+    # 'django.contrib.admin',
131
+    # Uncomment the next line to enable admin documentation:
132
+    # 'django.contrib.admindocs',
133
+    'laundry_app',
134
+    'south',
135
+)
136
+
137
+SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer'
138
+
139
+# A sample logging configuration. The only tangible logging
140
+# performed by this configuration is to send an email to
141
+# the site admins on every HTTP 500 error when DEBUG=False.
142
+# See http://docs.djangoproject.com/en/dev/topics/logging for
143
+# more details on how to customize your logging configuration.
144
+LOGGING = {
145
+    'version': 1,
146
+    'disable_existing_loggers': False,
147
+    'filters': {
148
+        'require_debug_false': {
149
+            '()': 'django.utils.log.RequireDebugFalse'
150
+        }
151
+    },
152
+    'handlers': {
153
+        'mail_admins': {
154
+            'level': 'ERROR',
155
+            'filters': ['require_debug_false'],
156
+            'class': 'django.utils.log.AdminEmailHandler'
157
+        }
158
+    },
159
+    'loggers': {
160
+        'django.request': {
161
+            'handlers': ['mail_admins'],
162
+            'level': 'ERROR',
163
+            'propagate': True,
164
+        },
165
+    }
166
+}

+ 18 - 0
laundry/urls.py

@@ -0,0 +1,18 @@
1
+from django.conf.urls import patterns, include, url
2
+
3
+# Uncomment the next two lines to enable the admin:
4
+from django.contrib import admin
5
+admin.autodiscover()
6
+
7
+urlpatterns = patterns('',
8
+    # Examples:
9
+    # url(r'^$', 'laundry.views.home', name='home'),
10
+    # url(r'^laundry/', include('laundry.foo.urls')),
11
+
12
+    # Uncomment the admin/doc line below to enable admin documentation:
13
+    # url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
14
+
15
+    # Uncomment the next line to enable the admin:
16
+    # url(r'^admin/', include(admin.site.urls)),
17
+    url(r'', include('laundry_app.urls')),
18
+)

+ 32 - 0
laundry/wsgi.py

@@ -0,0 +1,32 @@
1
+"""
2
+WSGI config for laundry project.
3
+
4
+This module contains the WSGI application used by Django's development server
5
+and any production WSGI deployments. It should expose a module-level variable
6
+named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover
7
+this application via the ``WSGI_APPLICATION`` setting.
8
+
9
+Usually you will have the standard Django WSGI application here, but it also
10
+might make sense to replace the whole Django WSGI application with a custom one
11
+that later delegates to the Django one. For example, you could introduce WSGI
12
+middleware here, or combine a Django application with an application of another
13
+framework.
14
+
15
+"""
16
+import os
17
+
18
+# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks
19
+# if running multiple sites in the same mod_wsgi process. To fix this, use
20
+# mod_wsgi daemon mode with each site in its own daemon process, or use
21
+# os.environ["DJANGO_SETTINGS_MODULE"] = "laundry.settings"
22
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "laundry.settings")
23
+
24
+# This application object is used by any WSGI server configured to use this
25
+# file. This includes Django's development server, if the WSGI_APPLICATION
26
+# setting points here.
27
+from django.core.wsgi import get_wsgi_application
28
+application = get_wsgi_application()
29
+
30
+# Apply WSGI middleware here.
31
+# from helloworld.wsgi import HelloWorldApplication
32
+# application = HelloWorldApplication(application)

+ 0 - 0
laundry_app/__init__.py


+ 120 - 0
laundry_app/laundry.py

@@ -0,0 +1,120 @@
1
+from BeautifulSoup import BeautifulSoup
2
+from urllib2 import urlopen
3
+import pygal
4
+from pygal.style import Style
5
+from models import LaundryMachine, LaundryRecord, Timeslot, LaundrySummary
6
+import datetime
7
+
8
+WASHER = LaundryMachine.WASHER
9
+DRYER = LaundryMachine.DRYER
10
+AVAILABLE = LaundryRecord.AVAILABLE
11
+IN_USE = LaundryRecord.IN_USE
12
+CYCLE_COMPLETE = LaundryRecord.CYCLE_COMPLETE
13
+UNAVAILABLE = LaundryRecord.UNAVAILABLE
14
+BASE_URL_QUERY = 'http://gmu.esuds.net/RoomStatus/machineStatus.i?bottomLocationId='
15
+
16
+def load_data(hall):
17
+    """Extract table data from esuds for specified hall"""
18
+    htsl = urlopen(BASE_URL_QUERY+str(int(hall.location_id))).read()
19
+    soup = BeautifulSoup(htsl) # Start cook'n
20
+    rows = [row for row in soup.findAll('tr')[1:] if len(row('td')) > 1]
21
+    return rows
22
+
23
+def get_num_machines_per_status(status, records):
24
+    """
25
+    Count the number of machines in a hall which have the desired status and
26
+    return a list that can be inputed to the pygal add series function.
27
+
28
+    Returns a list of two ints. The first element is the number of washers
29
+    with the desired status and the second is the number of dryers.
30
+    """
31
+    return [len(filter(lambda r: r.machine.type == WASHER and
32
+                r.availability == status, records)),
33
+            len(filter(lambda r: r.machine.type == DRYER and
34
+                r.availability == status, records))]
35
+
36
+def generate_current_chart(filepath, records, hall):
37
+    """
38
+    Generate stacked bar chart of current laundry usage for specified hall and
39
+    save svg at filepath.
40
+    """
41
+    custom_style = Style(colors=('#B6E354', '#FF5995', '#FEED6C', '#E41B17'))
42
+    chart = pygal.StackedBar(style=custom_style, width=800, height=512, explicit_size=True)
43
+    chart.title = 'Current laundry machine usage in ' + hall.name
44
+    chart.x_labels = ['Washers', 'Dryers']
45
+    chart.add('Available', get_num_machines_per_status(AVAILABLE, records))
46
+    chart.add('In Use', get_num_machines_per_status(IN_USE, records))
47
+    chart.add('Cycle Complete', get_num_machines_per_status(CYCLE_COMPLETE, records))
48
+    chart.add('Unavailable', get_num_machines_per_status(UNAVAILABLE, records))
49
+    chart.range = [0, 11]
50
+    chart.render_to_file(filepath)
51
+
52
+# NOTE: Abandoning generating the weekly chart via mysql and Django for now.
53
+# (cron script and csv file is just easier) Sorry if there are a lot of
54
+# skeletons lying around as a result of this abandonment.
55
+#def generate_weekly_chart(filepath, hall):
56
+    # First, add a new LaundrySummary for the most recent record.
57
+    # TODO: determine if this step actually needs to be done or not.
58
+    #records = list()
59
+    #for machine in hall.machines:
60
+        #records.append(machine.get_latest_record())
61
+    #ts = records[0].timeslot
62
+    #ts = ts - datetime.timedelta(minutes=ts.minute % 15,
63
+            #seconds=ts.second, microseconds=ts.microsecond)
64
+    #day = ts.weekday()
65
+    #slot = Timeslot.objects.get_or_create(hall=hall, time=ts.time(), day=day)
66
+    #LaundrySummary.objects.create(timeslot=slot,
67
+            #washers = len([w for w in records if
68
+                #(w.machine.type == LaundryMachine.WASHER and
69
+                #w.availability == LaundryRecord.AVAILABLE)]),
70
+            #dryers = len([w for w in records if
71
+                #(w.machine.type == LaundryMachine.DRYER and
72
+                #w.availability == LaundryRecord.AVAILABLE)]),
73
+    #)
74
+    # Now, actually generate the chart by adding all of the LaundrySummaries
75
+    # to the chart and making all of the Timeslots the x-axis.
76
+
77
+def update(hall, filepath=None):
78
+    """
79
+    Scrape data from esuds for the specified hall, create a new LaundryRecord
80
+    for each machine, and optionally save a new current chart for the hall.
81
+
82
+    To generate a current chart with this update, set filepath to a valid
83
+    filepath to save and svg file.
84
+    """
85
+    rows = load_data(hall)
86
+    records = list()
87
+    for row in rows:
88
+        cells = row('td')
89
+        # Some small laundry rooms (with only two machines) do not label their
90
+        # machines. Our database represents "unlabeled" as 0. We will just have
91
+        # to rely on the type of the machine when searching.
92
+        if str(cells[1].contents[0]) == "unlabeled":
93
+            number = 0
94
+        else:
95
+            number = int(cells[1].contents[0])
96
+        type = cells[2].contents[0]
97
+        # For some reason there is a distinction between normal and "stacked"
98
+        # washers/dryers in esuds; we only need normal, so cut "stacked" off.
99
+        if type == 'Stacked Dryer': type = type[8:]
100
+        if type == 'Stacked Washer': type = type[8:]
101
+        # Since Presidents Park - Harrison apparently has freak Washer/Dryer
102
+        # hybrids, we will just ignore the type field when getting the machine.
103
+        if type == 'Stacked Washer/Dryer': type = None
104
+        availability = cells[3].contents[1].contents[0]
105
+        time_remaining = None
106
+        if cells[4].contents[0] != ' ':
107
+            time_remaining = int(cells[4].contents[0])
108
+        if type:
109
+            machine = LaundryMachine.objects.get(number=number, type=type,
110
+                    hall=hall)
111
+        else: # Search without type, for Presidents Park - Harrison
112
+            machine = LaundryMachine.objects.get(number=number, hall=hall)
113
+        record = LaundryRecord(machine=machine, availability=availability,
114
+                time_remaining=time_remaining)
115
+        if filepath:
116
+            records.append(record)
117
+        else:
118
+            record.save()
119
+    if filepath:
120
+        generate_current_chart(filepath, records, hall)

+ 54 - 0
laundry_app/models.py

@@ -0,0 +1,54 @@
1
+from django.db import models
2
+
3
+class Hall(models.Model):
4
+    name = models.TextField(max_length=100)
5
+    location_id = models.IntegerField() # id used by esuds
6
+
7
+class LaundryMachine(models.Model):
8
+    WASHER = 'Washer'
9
+    DRYER = 'Dryer'
10
+    MACHINE_TYPES = (
11
+            ('Washer', 'Washer'),
12
+            ('Dryer', 'Dryer'),
13
+    )
14
+    number = models.PositiveSmallIntegerField() # number assigned by esuds
15
+    type = models.TextField(choices=MACHINE_TYPES)
16
+    hall = models.ForeignKey('Hall', related_name='machine')
17
+
18
+    def get_latest_record(self):
19
+        records = self.records.all()
20
+        return records[len(records)-1]
21
+
22
+class LaundryRecord(models.Model):
23
+    AVAILABLE = 'Available'
24
+    IN_USE = 'In Use'
25
+    CYCLE_COMPLETE = 'Cycle Complete'
26
+    UNAVAILABLE = 'Unavailable'
27
+    AVAILABILITIES = (
28
+            (AVAILABLE, 'Available'),
29
+            (IN_USE, 'In Use'),
30
+            (CYCLE_COMPLETE, 'Cycle Complete'),
31
+            (UNAVAILABLE, 'Unavailable'),
32
+    )
33
+    machine = models.ForeignKey('LaundryMachine', related_name='records')
34
+    availability = models.TextField(choices=AVAILABILITIES)
35
+    time_remaining = models.IntegerField(null=True)
36
+    timestamp = models.DateTimeField(auto_now_add=True)
37
+
38
+class Timeslot(models.Model):
39
+    hall = models.ForeignKey('Hall', related_name='timeslots')
40
+    day = models.SmallIntegerField()
41
+    time = models.TimeField()
42
+
43
+    def washer_avg(self):
44
+        summaries = self.summaries.all()
45
+        return (sum(s.washers for s in summaries) / (len(summaries)) or 1)
46
+
47
+    def dryer_avg(self):
48
+        summaries = self.summaries.all()
49
+        return (sum(s.dryers for s in summaries) / (len(summaries)) or 1)
50
+
51
+class LaundrySummary(models.Model):
52
+    timeslot = models.ForeignKey('Timeslot', related_name='summaries')
53
+    washers = models.IntegerField()
54
+    dryers = models.IntegerField()

+ 16 - 0
laundry_app/tests.py

@@ -0,0 +1,16 @@
1
+"""
2
+This file demonstrates writing tests using the unittest module. These will pass
3
+when you run "manage.py test".
4
+
5
+Replace this with more appropriate tests for your application.
6
+"""
7
+
8
+from django.test import TestCase
9
+
10
+
11
+class SimpleTest(TestCase):
12
+    def test_basic_addition(self):
13
+        """
14
+        Tests that 1 + 1 always equals 2.
15
+        """
16
+        self.assertEqual(1 + 1, 2)

+ 6 - 0
laundry_app/urls.py

@@ -0,0 +1,6 @@
1
+from django.conf.urls.defaults import patterns, url
2
+
3
+urlpatterns = patterns('laundry_app.views',
4
+    url(r'^ajax/current/(?P<hall>\d+)/$', 'ajax_get_current', name='get_current_chart'),
5
+    url(r'^$', 'main_page', name='laundry_main'),
6
+)

+ 29 - 0
laundry_app/views.py

@@ -0,0 +1,29 @@
1
+from django.template import RequestContext
2
+from django.shortcuts import render_to_response, get_object_or_404
3
+from models import Hall
4
+from django.conf import settings
5
+from django.http import HttpResponse
6
+from django.core.exceptions import ObjectDoesNotExist
7
+from os.path import join
8
+import laundry
9
+
10
+SVG_DIR = join(settings.PROJECT_PATH, 'laundry_app', 'static')
11
+SVG_URL = settings.STATIC_URL
12
+
13
+def main_page(request):
14
+    # pass the halls to the html, and let cookies/user decide which to pick
15
+    halls = [(hall.name, hall.id) for hall in
16
+             Hall.objects.all().order_by('name')]
17
+    return render_to_response('laundry/main.html', {
18
+            'halls': halls,
19
+            },
20
+            context_instance=RequestContext(request))
21
+
22
+def ajax_get_current(request, hall):
23
+    hall_obj = get_object_or_404(Hall, pk=hall)
24
+    filename = str(hall_obj.id) + '_current.svg'
25
+    try:
26
+        laundry.update(hall_obj, filepath=join(SVG_DIR, filename))
27
+    except ObjectDoesNotExist:
28
+        return HttpResponse(status=500);
29
+    return HttpResponse(join(SVG_URL, filename))

+ 10 - 0
manage.py

@@ -0,0 +1,10 @@
1
+#!/usr/bin/env python
2
+import os
3
+import sys
4
+
5
+if __name__ == "__main__":
6
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "laundry.settings")
7
+
8
+    from django.core.management import execute_from_command_line
9
+
10
+    execute_from_command_line(sys.argv)

+ 65 - 0
templates/laundry/main.html

@@ -0,0 +1,65 @@
1
+<!DOCTYPE html>
2
+<html>
3
+{% block head %}
4
+<head>
5
+    <meta charset="utf-8">
6
+    {% block title %}<title>GMU Laundry</title>{% endblock %}
7
+    {% block description %}
8
+    <meta name="Description" content="Current laundry machine usage charts for George Mason University's dorms. An experiment by Tyler Hallada.">
9
+    {% endblock %}
10
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
11
+    <link href="{{ STATIC_URL }}css/bootstrap.min.css" rel="stylesheet">
12
+    <link href="{{ STATIC_URL }}css/font-awesome.css" rel="stylesheet">
13
+    <link href="{{ STATIC_URL }}css/bootstrap-responsive.min.css" rel="stylesheet">
14
+    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.8.0/jquery.min.js"></script>
15
+    <script src="{{ STATIC_URL }}js/bootstrap.min.js"></script>
16
+    <script src="{{ STATIC_URL }}js/laundry.js"></script>
17
+    <script src="{{ STATIC_URL }}js/jquery.cookie.js"></script>
18
+    <link href="{{ STATIC_URL }}img/glyphicons-halflings.png" rel="icons">
19
+    <link href="{{ STATIC_URL }}css/style.css" rel="stylesheet">
20
+    <!-- Creative Commons - Attribution - The Noun Project -->
21
+    <link href="/favicon.ico" rel="shortcut icon">
22
+    {% block head-extra %}{% endblock %}
23
+</head>
24
+{% endblock %}
25
+<script>
26
+var halls = {
27
+    {% for h in halls %}
28
+    "{{ h.0 }}" : {{ h.1 }},
29
+    {% endfor %}
30
+}
31
+</script>
32
+<style>
33
+    body {
34
+        background:#000000;
35
+    }
36
+</style>
37
+<body class="laundry">
38
+    <div class="container">
39
+        <div class="row hall-select-container">
40
+            <select id="hall-select">
41
+                {% for h in halls %}
42
+                    {% if h.1 == 1 %}
43
+                        <option selected="selected">{{ h.0 }}</option>
44
+                    {% else %}
45
+                        <option>{{ h.0 }}</option>
46
+                    {% endif %}
47
+                {% endfor %}
48
+            </select>
49
+        </div>
50
+        <div class="row chart-container">
51
+            <div class="current-chart"></div>
52
+        </div>
53
+    </div>
54
+    <script>
55
+      (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
56
+      (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
57
+      m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
58
+      })(window,document,'script','//www.google-analytics.com/analytics.js','ga');
59
+
60
+      ga('create', 'UA-39880341-1', 'hallada.net');
61
+      ga('send', 'pageview');
62
+
63
+    </script>
64
+</body>
65
+</html>