├── server ├── main │ ├── __init__.py │ ├── static │ │ └── main │ │ │ ├── img │ │ │ ├── bg.jpg │ │ │ ├── ok.png │ │ │ ├── pee.png │ │ │ ├── dead.png │ │ │ ├── poop.png │ │ │ ├── toilet.png │ │ │ ├── favicon-free.ico │ │ │ ├── favicon-using.ico │ │ │ └── favicon-toilet.ico │ │ │ ├── js │ │ │ ├── toilet-stats-chart.js │ │ │ ├── reconnecting-websocket.min.js │ │ │ ├── toilet-status.js │ │ │ ├── countdown.min.js │ │ │ └── less-1.3.1.min.js │ │ │ └── css │ │ │ └── toilet.less │ ├── asgi.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── stats │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ ├── utils.py │ ├── views.py │ ├── stats.py │ └── templates │ │ └── toilet_stats.html ├── toilet │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_toiletlecture_total_time.py │ │ ├── 0004_auto_20170121_1942.py │ │ ├── 0002_auto_20170112_1435.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── consumers.py │ ├── routing.py │ ├── admin.py │ ├── urls.py │ ├── serializers.py │ ├── managers.py │ ├── templates │ │ ├── toilet_status.html │ │ └── base.html │ ├── views.py │ └── models.py ├── postgres_stats │ ├── __init__.py │ ├── aggregates.py │ └── functions.py ├── manage.py ├── app.ini.template ├── crons.yml └── setup.cfg ├── iot ├── __init__.py ├── config.py.template └── main.py ├── docker ├── system-requirements.txt ├── docker-entrypoint.sh ├── uwsgi.ini └── app.ini.docker ├── .editorconfig ├── README.rst ├── requirements.txt ├── .gitlab-ci.yml ├── docker-compose.yml ├── LICENSE ├── Dockerfile └── .gitignore /server/main/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/stats/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/toilet/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/stats/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/toilet/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iot/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /docker/system-requirements.txt: -------------------------------------------------------------------------------- 1 | mercurial 2 | git 3 | libldap2-dev 4 | libsasl2-dev 5 | -------------------------------------------------------------------------------- /server/postgres_stats/__init__.py: -------------------------------------------------------------------------------- 1 | from .functions import * 2 | from .aggregates import * -------------------------------------------------------------------------------- /server/stats/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /server/stats/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /server/stats/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /server/main/static/main/img/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/sauron/HEAD/server/main/static/main/img/bg.jpg -------------------------------------------------------------------------------- /server/main/static/main/img/ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/sauron/HEAD/server/main/static/main/img/ok.png -------------------------------------------------------------------------------- /server/main/static/main/img/pee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/sauron/HEAD/server/main/static/main/img/pee.png -------------------------------------------------------------------------------- /server/main/static/main/img/dead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/sauron/HEAD/server/main/static/main/img/dead.png -------------------------------------------------------------------------------- /server/main/static/main/img/poop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/sauron/HEAD/server/main/static/main/img/poop.png -------------------------------------------------------------------------------- /server/main/static/main/img/toilet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/sauron/HEAD/server/main/static/main/img/toilet.png -------------------------------------------------------------------------------- /server/stats/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class StatsConfig(AppConfig): 5 | name = 'stats' 6 | -------------------------------------------------------------------------------- /server/toilet/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ToiletConfig(AppConfig): 5 | name = 'toilet' 6 | -------------------------------------------------------------------------------- /server/main/static/main/img/favicon-free.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/sauron/HEAD/server/main/static/main/img/favicon-free.ico -------------------------------------------------------------------------------- /server/main/static/main/img/favicon-using.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/sauron/HEAD/server/main/static/main/img/favicon-using.ico -------------------------------------------------------------------------------- /server/main/static/main/img/favicon-toilet.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/APSL/sauron/HEAD/server/main/static/main/img/favicon-toilet.ico -------------------------------------------------------------------------------- /server/toilet/consumers.py: -------------------------------------------------------------------------------- 1 | from channels import Group 2 | 3 | 4 | def ws_connect(message): 5 | Group("stream").add(message.reply_channel) 6 | -------------------------------------------------------------------------------- /iot/config.py.template: -------------------------------------------------------------------------------- 1 | TOILET_URL = "http://localhost:8000/toilets/" 2 | TOILET_ID = 1 3 | WLAN_NAME = '' 4 | WLAN_PASS = '' 5 | MIN_LDR_VAL = 100 6 | MAX_LDR_VAL = 800 7 | -------------------------------------------------------------------------------- /server/toilet/routing.py: -------------------------------------------------------------------------------- 1 | from channels.routing import route 2 | from .consumers import ws_connect 3 | 4 | channel_routing = [ 5 | route("websocket.connect", ws_connect), 6 | ] 7 | -------------------------------------------------------------------------------- /server/stats/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .views import ToiletStatsView 4 | 5 | urlpatterns = [ 6 | url(r'^$', ToiletStatsView.as_view(), name='toilets_stats'), 7 | ] 8 | -------------------------------------------------------------------------------- /server/stats/utils.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | 3 | 4 | def get_local_hour(hour): 5 | now = timezone.now() 6 | now = now.replace(hour=hour) 7 | tz = timezone.get_current_timezone() 8 | return now.astimezone(tz).hour 9 | -------------------------------------------------------------------------------- /server/main/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "main.settings") 4 | os.environ.setdefault('DJANGO_CONFIGURATION', 'Base') 5 | 6 | from asgi_redis import RedisChannelLayer 7 | 8 | channel_layer = RedisChannelLayer( 9 | hosts=[("redis://redis:6379/10")], 10 | ) 11 | -------------------------------------------------------------------------------- /server/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "main.settings") 7 | os.environ.setdefault('DJANGO_CONFIGURATION', 'Base') 8 | 9 | from configurations.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /server/toilet/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import ToiletLecture, Toilet 4 | 5 | 6 | @admin.register(Toilet) 7 | class ToiletAdmin(admin.ModelAdmin): 8 | pass 9 | 10 | 11 | @admin.register(ToiletLecture) 12 | class ToiletLectureAdmin(admin.ModelAdmin): 13 | list_display = ('toilet', 'start_at', 'end_at', 'total_time', ) 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{html,js,json,css,less,yml,yaml,md}] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | if [ "$1" = 'uwsgi' ]; then 4 | exec uwsgi --ini=/uwsgi.ini 5 | elif [ "$1" = 'daphne' ]; then 6 | exec daphne -b 0.0.0.0 -p 8000 --root-path /app main.asgi:channel_layer -v2 7 | elif [ "$1" = 'runworker' ]; then 8 | exec python manage.py runworker -v2 9 | # elif [ "$1" = 'cron' ]; then 10 | # getcrons.py crons.yml > crontab 11 | # exec go-cron -file="crontab" 12 | fi 13 | exec "$@" -------------------------------------------------------------------------------- /server/stats/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import TemplateView 2 | 3 | from .stats import get_toilet_stats 4 | 5 | 6 | class ToiletStatsView(TemplateView): 7 | template_name = 'toilet_stats.html' 8 | 9 | def get_context_data(self, **kwargs): 10 | context = super(ToiletStatsView, self).get_context_data(**kwargs) 11 | days = self.request.GET.get('days') 12 | context['stats'] = get_toilet_stats(days) 13 | context['days'] = days 14 | return context 15 | -------------------------------------------------------------------------------- /server/main/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "main.settings") 13 | os.environ.setdefault('DJANGO_CONFIGURATION', 'Base') 14 | 15 | from configurations.wsgi import get_wsgi_application # noqa: E402 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /docker/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | master = True 3 | http= :8000 4 | #stats = :9090 5 | static-map = /static=/data/static 6 | static-map = /media=/data/media 7 | #socket = :3031 8 | processes = 1 9 | threads = 12 10 | harakiri = 60 11 | max-requests = 2000 12 | chdir = /app 13 | module = main.wsgi 14 | vacuum = true 15 | logformat = [%(ltime)] %(host) %(method) %(uri) %(status) %(msecs) ms %(rssM) MB 16 | #sigterm from docker stops 17 | die-on-term = True 18 | disable-logging = False 19 | memory-report = True 20 | log-master = True 21 | #touch-reload = /reload -------------------------------------------------------------------------------- /server/toilet/migrations/0003_toiletlecture_total_time.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.2 on 2017-01-21 18:39 3 | from __future__ import unicode_literals 4 | 5 | import datetime 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('toilet', '0002_auto_20170112_1435'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='toiletlecture', 18 | name='total_time', 19 | field=models.DurationField(default=datetime.timedelta(0)), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /server/toilet/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .views import ToiletsStatusView, ToiletsLastEventView, ToiletsLectureAPIView, ToiletsLastLectureView, \ 4 | ToiletLastUsageTimeView 5 | 6 | urlpatterns = [ 7 | url(r'^$', ToiletsStatusView.as_view(), name='toilets_status'), 8 | url(r'^toilets/last_event', ToiletsLastEventView.as_view(), name='toilets_last_event'), 9 | url(r'^toilets/last_lecture', ToiletsLastLectureView.as_view(), name='toilets_last_lecture'), 10 | url(r'^toilets', ToiletsLectureAPIView.as_view(), name='toilets_lecture'), 11 | url(r'^toilet/(?P[0-9]+)/last_usage_time', ToiletLastUsageTimeView.as_view(), name='toilet_last_usage_time'), 12 | ] 13 | -------------------------------------------------------------------------------- /server/toilet/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import ToiletLecture, Toilet 4 | 5 | 6 | class ToiletLectureSerializer(serializers.Serializer): 7 | in_use = serializers.BooleanField() 8 | toilet = serializers.PrimaryKeyRelatedField(queryset=Toilet.objects.all()) 9 | 10 | def save(self, **kwargs): 11 | toilet = self.validated_data['toilet'] 12 | last_lecture = ToiletLecture.last_active_lecture(toilet) 13 | if self.validated_data['in_use']: 14 | if not last_lecture: 15 | ToiletLecture.objects.create(toilet=toilet) 16 | else: 17 | if last_lecture: 18 | last_lecture.end_lecture() 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | sauron 3 | ====== 4 | Sauron is a project to monitor the usage of the toilets of `APSL `_ offices. It also collect the usage to further stats visualization. 5 | 6 | It uses a NodeMCU board programmed `Micropython `_ for the IoT side and Django with `Channels `_ as the web server. 7 | 8 | See `this blog post `_ for more info. 9 | 10 | Screenshots 11 | ~~~~~~~~~~~ 12 | 13 | Toilets status 14 | ============== 15 | 16 | .. image:: https://www.apsl.net/media/apslweb/images/Selection_063.width-800.png 17 | 18 | 19 | Toilets stats 20 | ============= 21 | 22 | .. image:: https://www.apsl.net/media/apslweb/images/Selection_061.width-800.png 23 | -------------------------------------------------------------------------------- /server/toilet/migrations/0004_auto_20170121_1942.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.2 on 2017-01-21 18:42 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | def calc_total_time(apps, schema_editor): 9 | ToiletLecture = apps.get_model("toilet", "ToiletLecture") 10 | for toilet_lecture in ToiletLecture.objects.all(): 11 | if toilet_lecture.end_at: 12 | toilet_lecture.total_time = toilet_lecture.end_at - toilet_lecture.start_at 13 | toilet_lecture.save() 14 | 15 | 16 | class Migration(migrations.Migration): 17 | 18 | dependencies = [ 19 | ('toilet', '0003_toiletlecture_total_time'), 20 | ] 21 | 22 | operations = [ 23 | migrations.RunPython(calc_total_time), 24 | ] 25 | -------------------------------------------------------------------------------- /server/main/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls import include, url 3 | from django.contrib import admin 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^', include('toilet.urls')), 8 | url(r'^stats/', include('stats.urls')), 9 | url(r'^admin/', include(admin.site.urls)), 10 | ] 11 | 12 | 13 | if settings.DEBUG: 14 | from django.conf.urls.static import static 15 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 16 | 17 | urlpatterns += staticfiles_urlpatterns() 18 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 19 | 20 | if settings.ENABLE_DEBUG_TOOLBAR: 21 | import debug_toolbar 22 | 23 | urlpatterns += [ 24 | url(r'^__debug__/', include(debug_toolbar.urls)), 25 | ] 26 | -------------------------------------------------------------------------------- /server/toilet/migrations/0002_auto_20170112_1435.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.2 on 2017-01-12 13:35 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('toilet', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameField( 16 | model_name='toiletlecture', 17 | old_name='created_at', 18 | new_name='start_at', 19 | ), 20 | migrations.RemoveField( 21 | model_name='toiletlecture', 22 | name='in_use', 23 | ), 24 | migrations.AddField( 25 | model_name='toiletlecture', 26 | name='end_at', 27 | field=models.DateTimeField(blank=True, null=True), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | args==0.1.0 2 | asgiref==1.0.0 3 | asgi_redis==1.0.0 4 | autobahn==0.17.0 5 | channels==0.17.3 6 | clint==0.5.1 7 | constantly==15.1.0 8 | contextlib2==0.5.4 9 | daphne==0.15.0 10 | Django==1.10.2 11 | django-appconf==1.0.2 12 | django-compressor==2.1 13 | django-configurations==2.0 14 | django-constance==1.3.3 15 | django-extensions==1.7.2 16 | django-kaio==0.4.0 17 | django-logentry-admin==1.0.2 18 | django-picklefield==0.3.2 19 | django-redis==4.4.4 20 | django-robots==2.0 21 | django-yubin==0.3.0 22 | djangorestframework==3.5.3 23 | incremental==16.10.1 24 | lockfile==0.12.2 25 | oauthlib==2.0.1 26 | psycopg2==2.6.2 27 | pytz==2016.6.1 28 | pyzmail==1.0.3 29 | raven==5.27.1 30 | rcssmin==1.0.6 31 | redis==2.10.5 32 | requests==2.12.4 33 | requests-oauthlib==0.7.0 34 | rjsmin==1.0.12 35 | six==1.10.0 36 | Twisted==16.6.0 37 | txaio==2.5.2 38 | zope.interface==4.3.3 39 | django-timedeltatemplatefilter==0.1.2 40 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: hub.apsl.net/library/gitlab-builder:latest 2 | before_script: 3 | - docker info 4 | - export VERSION=${CI_PIPELINE_ID}-${CI_BUILD_REF:0:8} 5 | - export IMAGE=${DOCKER_REGISTRY}/apsl/onlysmellz:$CI_BUILD_ID-${CI_BUILD_REF:0:8} 6 | stages: 7 | - build 8 | - push 9 | - deploy_prod 10 | build: 11 | stage: build 12 | script: 13 | - docker-compose -f build.yml build 14 | push: 15 | stage: push 16 | only: 17 | - master 18 | script: 19 | - echo "Tagging the build with the name -> ${IMAGE}" 20 | - docker tag onlysmellz:build ${IMAGE} 21 | - echo "Pushing the build to $DOCKER_REGISTRY , using the user $DOCKER_LOGIN" 22 | - docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD $DOCKER_REGISTRY 23 | - docker push ${IMAGE} 24 | prod: 25 | stage: deploy_prod 26 | only: 27 | - master 28 | when: manual 29 | script: 30 | - echo "$IMAGE -> Deploy manual en mamut (de momento)" 31 | -------------------------------------------------------------------------------- /server/app.ini.template: -------------------------------------------------------------------------------- 1 | [Paths] 2 | APP_ROOT = 3 | STATIC_ROOT = 4 | MEDIA_ROOT = 5 | 6 | [Logs] 7 | SENTRY_ENABLED = False 8 | SENTRY_DSN = 9 | LOG_LEVEL = DEBUG 10 | DJANGO_LOG_LEVEL = INFO 11 | 12 | [Database] 13 | DATABASE_USER = 14 | DATABASE_HOST = 15 | DATABASE_ENGINE = 16 | DATABASE_NAME = 17 | DATABASE_PORT = 18 | DATABASE_PASSWORD = 19 | DATABASE_CONN_MAX_AGE = 30 20 | 21 | [Base] 22 | APP_SLUG = sauron 23 | 24 | [Security] 25 | SECRET_KEY = 26 | ALLOWED_HOSTS = * 27 | 28 | [Debug] 29 | DEBUG = True 30 | TEMPLATE_DEBUG = True 31 | ENABLE_DEBUG_TOOLBAR = True 32 | 33 | [WhiteNoise] 34 | ENABLE_WHITENOISE = False 35 | 36 | [Cache] 37 | CACHE_TYPE = dummy 38 | 39 | [WebSockets] 40 | WEB_SOCKET_HOST = 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | redis: 4 | image: redis 5 | postgres: 6 | image: apsl/postgres 7 | environment: 8 | PGDATA: /var/lib/postgresql/data/pgdata 9 | POSTGRES_USER: uonlysmellz 10 | POSTGRES_DB: onlysmellzdb 11 | POSTGRES_PASSWORD: pass 12 | volumes: 13 | - "./data/postgres:/var/lib/postgresql/data" 14 | uwsgi: 15 | build: 16 | context: . 17 | dockerfile: Dockerfile 18 | image: onlysmellz:latest 19 | command: uwsgi 20 | ports: 21 | - 8000:8000 22 | links: 23 | - redis 24 | daphne: 25 | build: 26 | context: . 27 | dockerfile: Dockerfile 28 | image: onlysmellz:latest 29 | command: daphne 30 | ports: 31 | - 8001:8000 32 | links: 33 | - redis 34 | worker: 35 | build: 36 | context: . 37 | dockerfile: Dockerfile 38 | image: onlysmellz:latest 39 | command: runworker 40 | links: 41 | - redis -------------------------------------------------------------------------------- /server/toilet/managers.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | 3 | from django.db import models 4 | from django.db.models import Count, Avg, Max, Sum, DurationField 5 | from postgres_stats import Percentile 6 | 7 | 8 | class ToiletLectureQuerySet(models.QuerySet): 9 | metrics = {'total_time': Sum('total_time'), 'max_time': Max('total_time'), 'avg_time': Avg('total_time'), 10 | 'total_visits': Count('id'), 'median_time': Percentile('total_time', 0.5, output_field=DurationField())} 11 | 12 | def by_days(self, days): 13 | today = date.today() 14 | return self.filter(start_at__date__lte=today, start_at__date__gte=today-timedelta(days=int(days))) 15 | 16 | def group_by_hours(self): 17 | return self.extra({'hour': "extract(hour from start_at)"}).order_by('hour').values('hour').annotate( 18 | total_visits=Count('id'), total_time=Sum('total_time')) 19 | 20 | def get_summary(self): 21 | return self.aggregate(**self.metrics) 22 | -------------------------------------------------------------------------------- /server/crons.yml: -------------------------------------------------------------------------------- 1 | #automatización de tareas para comandos controlados por aploy 2 | 3 | #command1: 4 | # minute: '*/15' 5 | # hour: '0' 6 | # monthday: '24' 7 | # month: '12' 8 | # weekday: '1' 9 | # command: 'command_name' 10 | # parameters: 11 | # - "param1" 12 | # - "--param2 param2" 13 | # - "--param3 param3" 14 | # 15 | #command2: 16 | # minute: '30' 17 | # hour: '15' 18 | # command: 'otro_command' 19 | 20 | # Nota que solo es necesario especificar los valores diferentes a '*', si no ese será el valor por defecto. 21 | #NOTA importante, si una app está marcada como que se gestionan automáticamente los crons y no se define el archivo o no se marca un entorno para que configure los crons, se vaciará el crontab del usuario al actualizar. 22 | 23 | #ejemplo: 24 | # envío de e-mails desde django yubin 25 | 26 | send_mail: 27 | minute: '*/1' 28 | command: 'send_mail' 29 | 30 | #retry_deferred: 31 | # minute: '9,29,49' 32 | # command: 'retry_deferred' 33 | 34 | -------------------------------------------------------------------------------- /iot/main.py: -------------------------------------------------------------------------------- 1 | import time 2 | import network 3 | import urequests 4 | from machine import ADC 5 | 6 | from config import WLAN_NAME, WLAN_PASS, TOILET_URL, TOILET_ID, MIN_LDR_VAL, MAX_LDR_VAL 7 | 8 | 9 | def do_connect(): 10 | sta_if = network.WLAN(network.STA_IF) 11 | if not sta_if.isconnected(): 12 | print("Connecting") 13 | sta_if.active(True) 14 | sta_if.connect(WLAN_NAME, WLAN_PASS) 15 | while not sta_if.isconnected(): 16 | pass 17 | 18 | 19 | def send_data(in_use): 20 | do_connect() 21 | r = urequests.post(TOILET_URL, json={"toilet": TOILET_ID, "in_use": in_use}, 22 | headers={'content-type': 'application/json'}) 23 | r.close() 24 | 25 | 26 | adc = ADC(0) 27 | light_on = False 28 | 29 | while True: 30 | print(adc.read()) 31 | if adc.read() < MIN_LDR_VAL and light_on: 32 | light_on = False 33 | send_data(in_use=False) 34 | elif adc.read() > MAX_LDR_VAL and not light_on: 35 | light_on = True 36 | send_data(in_use=True) 37 | time.sleep(1) 38 | -------------------------------------------------------------------------------- /server/setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | 3 | ; https://pytest-django.readthedocs.org/en/latest/configuring_django.html 4 | DJANGO_SETTINGS_MODULE = main.settings 5 | DJANGO_CONFIGURATION = Test 6 | 7 | ; https://pytest-django.readthedocs.io/en/latest/database.html#tests-requiring-multiple-databases 8 | addopts = --reuse-db --nomigrations --flake8 --splinter-screenshot-dir=tests_failures 9 | 10 | ; Running test in paralell http://doc.pytest.org/en/latest/xdist.html 11 | ;addopts = --reuse-db --nomigrations --flake8 --splinter-screenshot-dir=tests_failures -n auto 12 | 13 | ; http://doc.pytest.org/en/latest/example/markers.html 14 | markers = 15 | unit_test: Pure unit tests. 16 | integration_test: Tests that access a database, API, etc. 17 | functional_test: End to end tests that needs a browser. 18 | 19 | norecursedirs = migrations node_modules 20 | 21 | 22 | [flake8] 23 | 24 | ; http://pep8.readthedocs.io/en/latest/intro.html#error-codes 25 | ; http://flake8.pycqa.org/en/latest/user/error-codes.html 26 | ;ignore = E501 E116 27 | max-line-length = 120 28 | max-complexity = 10 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 APSL 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/toilet/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.2 on 2016-12-27 20:15 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Toilet', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=180)), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='ToiletLecture', 26 | fields=[ 27 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('created_at', models.DateTimeField(auto_now_add=True)), 29 | ('in_use', models.BooleanField()), 30 | ('toilet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='toilet.Toilet')), 31 | ], 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /server/main/static/main/js/toilet-stats-chart.js: -------------------------------------------------------------------------------- 1 | function newChart(container_id, categories, visit_values, time_values) { 2 | Highcharts.chart(container_id, { 3 | chart: { 4 | type: 'column', 5 | width: 1100 6 | }, 7 | title: { 8 | text: '' 9 | }, 10 | xAxis: { 11 | "categories": categories, 12 | "title": {"text": "Hour"} 13 | }, 14 | yAxis: [{ 15 | title: { 16 | text: 'Visits' 17 | } 18 | }, { 19 | title: { 20 | text: 'Time (hours)' 21 | }, 22 | opposite: true 23 | }], 24 | legend: { 25 | shadow: false 26 | }, 27 | tooltip: { 28 | shared: true 29 | }, 30 | plotOptions: { 31 | column: { 32 | grouping: false, 33 | shadow: false, 34 | borderWidth: 0 35 | } 36 | }, 37 | series: [{ 38 | name: 'Visits', 39 | data: visit_values, 40 | pointPadding: 0.3, 41 | pointPlacement: -0.2 42 | }, { 43 | name: 'Time', 44 | data: time_values, 45 | pointPadding: 0.4, 46 | pointPlacement: -0.2, 47 | yAxis: 1 48 | }] 49 | } 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5 2 | 3 | MAINTAINER Edu Herraiz 4 | 5 | # ENV NVM_VERSION v0.32.1 6 | # ENV NODE_VERSION 6.9.1 7 | # ENV NVM_DIR /app/.nvm 8 | # Install system requirements 9 | # COPY docker/system-requirements.txt /system-requirements.txt 10 | # RUN \ 11 | # apt-get update \ 12 | # && apt-get -y upgrade \ 13 | # && apt-get -y autoremove \ 14 | # && xargs apt-get -y -q install < /system-requirements.txt \ 15 | # && rm -rf /var/lib/apt/lists/* 16 | 17 | RUN pip install uwsgi 18 | 19 | WORKDIR /app 20 | 21 | # Install Nvm and Nodejs 22 | # RUN git clone https://github.com/creationix/nvm.git /app/.nvm -b $NVM_VERSION 23 | # RUN echo $NODE_VERSION > /app/.nvmrc 24 | # COPY package.json /app/package.json 25 | # RUN chmod +x $NVM_DIR/nvm.sh 26 | # RUN \. $NVM_DIR/nvm.sh && nvm install && nvm alias default $NODE_VERSION && npm install 27 | 28 | COPY requirements.txt /requirements.txt 29 | # ignore-installed added for this problem with overlay fs: https://github.com/docker/docker/issues/12327 30 | RUN pip install --ignore-installed -r /requirements.txt 31 | 32 | COPY docker/uwsgi.ini /uwsgi.ini 33 | COPY server /app 34 | 35 | COPY docker/app.ini.docker /app/app.ini 36 | RUN python manage.py collectstatic --noinput 37 | 38 | VOLUME /app 39 | VOLUME /data 40 | COPY docker/docker-entrypoint.sh / 41 | ENTRYPOINT ["/docker-entrypoint.sh"] 42 | CMD ["uwsgi"] 43 | EXPOSE 8000 3031 -------------------------------------------------------------------------------- /server/stats/stats.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from toilet.models import ToiletLecture, Toilet 4 | from .utils import get_local_hour 5 | 6 | 7 | def get_chart_values(hourly_values): 8 | values = {'hour_values': [], 'visit_values': [], 'time_values': []} 9 | for hourly_value in hourly_values: 10 | values['hour_values'].append(get_local_hour(int(hourly_value['hour']))) 11 | values['visit_values'].append(hourly_value['total_visits']) 12 | values['time_values'].append(round(hourly_value['total_time'].total_seconds() / 3600, 2)) 13 | return values 14 | 15 | 16 | def get_toilet_stats(days): 17 | # Remove lectures with a duration of less of 10 seconds 18 | tl = ToiletLecture.objects.filter(total_time__gte=timedelta(seconds=10)) 19 | 20 | # Filter by days 21 | if days: 22 | tl = tl.by_days(days) 23 | 24 | # Get the summary and the hourly values of all the toilets 25 | stats = [{'name': 'All', 'summary': tl.get_summary(), 'chart_values': get_chart_values(tl.group_by_hours())}] 26 | 27 | # Get the summary and the hourly values of each toilet 28 | for toilet in Toilet.objects.all(): 29 | tl_by_toilet = tl.filter(toilet=toilet) 30 | stats.append({'name': toilet.name, 'summary': tl_by_toilet.get_summary(), 31 | 'chart_values': get_chart_values(tl_by_toilet.group_by_hours())}) 32 | return stats 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /docker/app.ini.docker: -------------------------------------------------------------------------------- 1 | [Base] 2 | APP_SLUG = onlysmellz 3 | SECRET_KEY = eed7lieyei2nimoh6eu9thee8Aivophu 4 | APP_ROOT = /app 5 | STATIC_URL = /static/ 6 | STATIC_ROOT = /data/static 7 | MEDIA_URL = /media/ 8 | MEDIA_ROOT = /data/media 9 | 10 | [Database] 11 | DATABASE_USER = uonlysmellz 12 | DATABASE_HOST = postgres 13 | DATABASE_ENGINE = postgresql_psycopg2 14 | DATABASE_NAME = onlysmellzdb 15 | DATABASE_PORT = 5432 16 | DATABASE_PASSWORD = pass 17 | 18 | [Logs] 19 | SENTRY_ENABLED = False 20 | SENTRY_DSN = 21 | LOG_LEVEL = INFO 22 | DJANGO_LOG_LEVEL = ERROR 23 | 24 | [Debug] 25 | DEBUG = False 26 | TEMPLATE_DEBUG = False 27 | 28 | [Security] 29 | ALLOWED_HOSTS = * 30 | 31 | [WhiteNoise] 32 | ENABLE_WHITENOISE = False 33 | 34 | [Compress] 35 | COMPRESS_ENABLED = True 36 | COMPRESS_OFFLINE = True 37 | COMPRESS_CSS_HASHING_METHOD = content 38 | COMPRESS_LESSC_PATH = /app/node_modules/.bin/lessc 39 | COMPRESS_BABEL_PATH = /app/node_modules/.bin/babel 40 | 41 | [Cache] 42 | REDIS_HOST = redis 43 | CACHE_TYPE = redis 44 | CACHE_REDIS_DB = 0 45 | REDIS_PORT = 6379 46 | CACHE_MAX_ENTRIES = 10000 47 | CACHE_TIMEOUT = 3600 48 | CACHE_PREFIX = onlysmellz 49 | 50 | -------------------------------------------------------------------------------- /server/toilet/templates/toilet_status.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n static %} 3 | 4 | {% block title %}Toilet Status{% endblock title %} 5 | {% block heading %}

Is the toilet Free?

{% endblock heading %} 6 | 7 | {% block content %} 8 |
9 |
10 | {% for toilet in toilets %} 11 |
12 |
13 |

{{ toilet.name }}

14 |

15 |

16 |
17 | (last usage time: ) 18 |
19 |
20 |
21 | {% endfor %} 22 |
23 |
24 | 25 | 26 | {% endblock content %} 27 | 28 | {% block page_js %} 29 | 30 | 31 | 32 | 33 | 34 | {% endblock page_js %} 35 | -------------------------------------------------------------------------------- /server/toilet/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.conf import settings 4 | from django.http import HttpResponse 5 | from django.views import View 6 | from django.views.generic import TemplateView 7 | 8 | from channels import Group 9 | from rest_framework import generics, status 10 | from rest_framework.response import Response 11 | from rest_framework.views import APIView 12 | 13 | from .serializers import ToiletLectureSerializer 14 | from .models import ToiletLecture, Toilet 15 | 16 | 17 | class ToiletsStatusView(TemplateView): 18 | template_name = 'toilet_status.html' 19 | 20 | def get_context_data(self, **kwargs): 21 | context = super(ToiletsStatusView, self).get_context_data(**kwargs) 22 | context['toilets'] = Toilet.objects.all() 23 | context['ws_host'] = settings.WEB_SOCKET_HOST 24 | return context 25 | 26 | 27 | class ToiletsLastEventView(View): 28 | 29 | def get(self, request, *args, **kwargs): 30 | Group("stream").send({"text": json.dumps(ToiletLecture.last_lectures())}) 31 | return HttpResponse() 32 | 33 | 34 | class ToiletsLectureAPIView(APIView): 35 | 36 | def post(self, request, *args, **kwargs): 37 | serializer = ToiletLectureSerializer(data=request.data) 38 | if serializer.is_valid(): 39 | serializer.save() 40 | return Response(None, status=status.HTTP_200_OK) 41 | return Response(status=status.HTTP_400_BAD_REQUEST) 42 | 43 | 44 | class ToiletsLastLectureView(generics.GenericAPIView): 45 | 46 | def get(self, request, *args, **kwargs): 47 | return Response(ToiletLecture.last_lectures()) 48 | 49 | 50 | class ToiletLastUsageTimeView(generics.GenericAPIView): 51 | 52 | def get(self, request, *args, **kwargs): 53 | return Response({'usage_time': int(ToiletLecture.last_usage_time(toilet_id=self.kwargs['pk']))}) 54 | -------------------------------------------------------------------------------- /server/toilet/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import timedelta 3 | 4 | from django.db import models 5 | from django.utils import timezone 6 | from channels import Group 7 | 8 | from .managers import ToiletLectureQuerySet 9 | 10 | 11 | class Toilet(models.Model): 12 | name = models.CharField(max_length=180) 13 | 14 | def __str__(self): 15 | return self.name 16 | 17 | 18 | class ToiletLecture(models.Model): 19 | start_at = models.DateTimeField(auto_now_add=True) 20 | end_at = models.DateTimeField(null=True, blank=True) 21 | total_time = models.DurationField(default=timedelta(seconds=0)) 22 | toilet = models.ForeignKey(Toilet) 23 | 24 | objects = ToiletLectureQuerySet.as_manager() 25 | 26 | def __str__(self): 27 | return '{} - {}'.format(self.toilet, self.start_at) 28 | 29 | @classmethod 30 | def last_active_lecture(cls, toilet): 31 | return ToiletLecture.objects.filter(toilet=toilet, end_at__isnull=True).last() 32 | 33 | @classmethod 34 | def last_lectures(cls): 35 | lecture = [] 36 | for toilet in Toilet.objects.all(): 37 | toilet_lecture = ToiletLecture.objects.filter(toilet=toilet).last() 38 | if toilet_lecture: 39 | end_date = None 40 | if toilet_lecture.end_at: 41 | end_date = toilet_lecture.end_at.isoformat() 42 | lecture.append({'toilet_id': toilet.id, 'in_use': not bool(toilet_lecture.end_at), 43 | 'start_at': toilet_lecture.start_at.isoformat(), 'end_at': end_date}) 44 | return lecture 45 | 46 | @classmethod 47 | def last_usage_time(cls, toilet_id): 48 | last_toilet_lecture = ToiletLecture.objects.filter(toilet_id=toilet_id, end_at__isnull=False).last() 49 | if last_toilet_lecture: 50 | return last_toilet_lecture.total_time.total_seconds() 51 | return 0 52 | 53 | def end_lecture(self): 54 | self.end_at = timezone.now() 55 | self.total_time = self.end_at - self.start_at 56 | self.save() 57 | 58 | def save(self, *args, **kwargs): 59 | super().save(*args, **kwargs) 60 | Group("stream").send({"text": json.dumps(self.last_lectures())}) 61 | -------------------------------------------------------------------------------- /server/toilet/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static i18n %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% block favicon %} 12 | 13 | {% endblock favicon %} 14 | 15 | {% block title %}{% endblock title %} 16 | 17 | {% block css %} 18 | 19 | 20 | 21 | 22 | {% endblock %} 23 | 24 | {% block page_css %}{% endblock page_css %} 25 | 26 | 27 | 28 |
29 | 37 |
38 |
39 |
40 | {% block heading %}{% endblock heading %} 41 |
42 |
43 |
44 |
45 | 46 | {% block content %}{% endblock content %} 47 | 48 | {% block javascript %} 49 | 50 | 51 | 52 | {% endblock javascript %} 53 | 54 | {% block page_js %}{% endblock page_js %} 55 | 56 | 57 | -------------------------------------------------------------------------------- /server/postgres_stats/aggregates.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Aggregate 2 | 3 | 4 | class Percentile(Aggregate): 5 | """ 6 | Accepts a numerical field or expression and a list of fractions and 7 | returns values for each fraction given corresponding to that fraction in 8 | that expression. 9 | 10 | If *continuous* is True (the default), the value will be interpolated 11 | between adjacent values if needed. Otherwise, the value will be the first 12 | input value whose position in the ordering equals or exceeds the 13 | specified fraction. 14 | 15 | You will likely have to declare the *output_field* for your results. 16 | Django cannot guess what type of value will be returned. 17 | 18 | Usage example:: 19 | 20 | from django.contrib.postgres.fields import ArrayField 21 | 22 | numbers = [31, 83, 237, 250, 305, 314, 439, 500, 520, 526, 527, 533, 23 | 540, 612, 831, 854, 857, 904, 928, 973] 24 | for n in numbers: 25 | Number.objects.create(n=n) 26 | 27 | results = Number.objects.all().aggregate( 28 | median=Percentile('n', 0.5, output_field=models.FloatField())) 29 | assert results['median'] == 526.5 30 | 31 | results = Number.objects.all().aggregate( 32 | quartiles=Percentile('n', [0.25, 0.5, 0.75], 33 | output_field=ArrayField(models.FloatField()))) 34 | assert results['quartiles'] == [311.75, 526.5, 836.75] 35 | 36 | results = Number.objects.all().aggregate( 37 | quartiles=Percentile('n', [0.25, 0.5, 0.75], 38 | continuous=False, 39 | output_field=ArrayField(models.FloatField()))) 40 | assert results['quartiles'] == [305, 526, 831] 41 | """ 42 | 43 | function = None 44 | name = "percentile" 45 | template = "%(function)s(%(percentiles)s) WITHIN GROUP (ORDER BY %(" \ 46 | "expressions)s)" 47 | 48 | def __init__(self, expression, percentiles, continuous=True, **extra): 49 | if isinstance(percentiles, (list, tuple)): 50 | percentiles = "array%(percentiles)s" % {'percentiles': percentiles} 51 | if continuous: 52 | extra['function'] = 'PERCENTILE_CONT' 53 | else: 54 | extra['function'] = 'PERCENTILE_DISC' 55 | super().__init__(expression, percentiles=percentiles, **extra) 56 | -------------------------------------------------------------------------------- /server/main/static/main/js/reconnecting-websocket.min.js: -------------------------------------------------------------------------------- 1 | !function(a,b){"function"==typeof define&&define.amd?define([],b):"undefined"!=typeof module&&module.exports?module.exports=b():a.ReconnectingWebSocket=b()}(this,function(){function a(b,c,d){function l(a,b){var c=document.createEvent("CustomEvent");return c.initCustomEvent(a,!1,!1,b),c}var e={debug:!1,automaticOpen:!0,reconnectInterval:1e3,maxReconnectInterval:3e4,reconnectDecay:1.5,timeoutInterval:2e3};d||(d={});for(var f in e)this[f]="undefined"!=typeof d[f]?d[f]:e[f];this.url=b,this.reconnectAttempts=0,this.readyState=WebSocket.CONNECTING,this.protocol=null;var h,g=this,i=!1,j=!1,k=document.createElement("div");k.addEventListener("open",function(a){g.onopen(a)}),k.addEventListener("close",function(a){g.onclose(a)}),k.addEventListener("connecting",function(a){g.onconnecting(a)}),k.addEventListener("message",function(a){g.onmessage(a)}),k.addEventListener("error",function(a){g.onerror(a)}),this.addEventListener=k.addEventListener.bind(k),this.removeEventListener=k.removeEventListener.bind(k),this.dispatchEvent=k.dispatchEvent.bind(k),this.open=function(b){h=new WebSocket(g.url,c||[]),b||k.dispatchEvent(l("connecting")),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","attempt-connect",g.url);var d=h,e=setTimeout(function(){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","connection-timeout",g.url),j=!0,d.close(),j=!1},g.timeoutInterval);h.onopen=function(){clearTimeout(e),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onopen",g.url),g.protocol=h.protocol,g.readyState=WebSocket.OPEN,g.reconnectAttempts=0;var d=l("open");d.isReconnect=b,b=!1,k.dispatchEvent(d)},h.onclose=function(c){if(clearTimeout(e),h=null,i)g.readyState=WebSocket.CLOSED,k.dispatchEvent(l("close"));else{g.readyState=WebSocket.CONNECTING;var d=l("connecting");d.code=c.code,d.reason=c.reason,d.wasClean=c.wasClean,k.dispatchEvent(d),b||j||((g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onclose",g.url),k.dispatchEvent(l("close")));var e=g.reconnectInterval*Math.pow(g.reconnectDecay,g.reconnectAttempts);setTimeout(function(){g.reconnectAttempts++,g.open(!0)},e>g.maxReconnectInterval?g.maxReconnectInterval:e)}},h.onmessage=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onmessage",g.url,b.data);var c=l("message");c.data=b.data,k.dispatchEvent(c)},h.onerror=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onerror",g.url,b),k.dispatchEvent(l("error"))}},1==this.automaticOpen&&this.open(!1),this.send=function(b){if(h)return(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","send",g.url,b),h.send(b);throw"INVALID_STATE_ERR : Pausing to reconnect websocket"},this.close=function(a,b){"undefined"==typeof a&&(a=1e3),i=!0,h&&h.close(a,b)},this.refresh=function(){h&&h.close()}}return a.prototype.onopen=function(){},a.prototype.onclose=function(){},a.prototype.onconnecting=function(){},a.prototype.onmessage=function(){},a.prototype.onerror=function(){},a.debugAll=!1,a.CONNECTING=WebSocket.CONNECTING,a.OPEN=WebSocket.OPEN,a.CLOSING=WebSocket.CLOSING,a.CLOSED=WebSocket.CLOSED,a}); 2 | -------------------------------------------------------------------------------- /server/postgres_stats/functions.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Aggregate, Func 2 | 3 | 4 | class DateTrunc(Func): 5 | """ 6 | Accepts a single timestamp field or expression and returns that timestamp 7 | truncated to the specified *precision*. This is useful for investigating 8 | time series. 9 | 10 | The *precision* named parameter can take: 11 | 12 | * microseconds 13 | * milliseconds 14 | * second 15 | * minute 16 | * hour 17 | * day 18 | * week 19 | * month 20 | * quarter 21 | * year 22 | * decade 23 | * century 24 | * millennium 25 | 26 | Usage example:: 27 | 28 | checkin = Checkin.objects. 29 | annotate(day=DateTrunc('logged_at', 'day'), 30 | hour=DateTrunc('logged_at', 'hour')). 31 | get(pk=1) 32 | 33 | assert checkin.logged_at == datetime(2015, 11, 1, 10, 45, 0) 34 | assert checkin.day == datetime(2015, 11, 1, 0, 0, 0) 35 | assert checkin.hour == datetime(2015, 11, 1, 10, 0, 0) 36 | """ 37 | 38 | function = "DATE_TRUNC" 39 | template = "%(function)s('%(precision)s', %(expressions)s)" 40 | 41 | def __init__(self, expression, precision, **extra): 42 | super().__init__(expression, precision=precision, **extra) 43 | 44 | 45 | class Extract(Func): 46 | """ 47 | Accepts a single timestamp or interval field or expression and returns 48 | the specified *subfield* of that expression. This is useful for grouping 49 | data. 50 | 51 | The *subfield* named parameter can take: 52 | 53 | * century 54 | * day 55 | * decade 56 | * dow (day of week) 57 | * doy (day of year) 58 | * epoch (seconds since 1970-01-01 00:00:00 UTC) 59 | * hour 60 | * isodow 61 | * isodoy 62 | * isoyear 63 | * microseconds 64 | * millennium 65 | * milliseconds 66 | * minute 67 | * month 68 | * quarter 69 | * second 70 | * timezone 71 | * timezone_hour 72 | * timezone_minute 73 | * week 74 | * year 75 | 76 | See `the Postgres documentation`_ for details about the subfields. 77 | 78 | Usage example:: 79 | 80 | checkin = Checkin.objects. 81 | annotate(day=Extract('logged_at', 'day'), 82 | minute=Extract('logged_at', 'minute'), 83 | quarter=Extract('logged_at', 'quarter')). 84 | get(pk=1) 85 | 86 | assert checkin.logged_at == datetime(2015, 11, 1, 10, 45, 0) 87 | assert checkin.day == 1 88 | assert checkin.minute == 45 89 | assert checkin.quarter == 4 90 | 91 | .. _the Postgres documentation: http://www.postgresql.org/docs/current/static/functions-datetime.html#FUNCTIONS-DATETIME-EXTRACT 92 | 93 | """ 94 | function = 'EXTRACT' 95 | name = 'extract' 96 | template = "%(function)s(%(subfield)s FROM %(expressions)s)" 97 | 98 | def __init__(self, expression, subfield, **extra): 99 | super().__init__(expression, subfield=subfield, **extra) 100 | 101 | 102 | -------------------------------------------------------------------------------- /server/stats/templates/toilet_stats.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n static timedelta_filter %} 3 | 4 | {% block favicon %} 5 | 6 | {% endblock favicon %} 7 | 8 | {% block title %}Toilet Stats{% endblock title %} 9 | 10 | {% block heading %}

Toilet Stats

{% endblock heading %} 11 | 12 | {% block content %} 13 |
14 |
15 |
16 |
17 | 24 | 30 |
31 |
32 | 33 | 34 | 35 | {% for item in stats %} 36 |
37 |
38 |
    39 |
  • Total time: {{ item.summary.total_time|default_if_none:"0"|timedelta:"{hours_total} hours, {minutes} minutes, {seconds} seconds"}}
  • 40 |
  • Max time: {{ item.summary.max_time|default_if_none:"0"|timedelta:"{minutes}:{seconds2}" }}
  • 41 |
  • Average time: {{ item.summary.avg_time|default_if_none:"0"|timedelta:"{minutes}:{seconds2}" }}
  • 42 |
  • Median time: {{ item.summary.median_time|default_if_none:"0"|timedelta:"{minutes}:{seconds2}" }}
  • 43 |
  • Total visits: {{ item.summary.total_visits }}
  • 44 |
45 |
46 |
47 | 51 |
52 | {% endfor %} 53 |
54 |
55 |
56 | 57 | 58 |
59 | {% endblock content %} 60 | {% block page_js %} 61 | 62 | {% endblock page_js %} 63 | -------------------------------------------------------------------------------- /server/main/static/main/js/toilet-status.js: -------------------------------------------------------------------------------- 1 | function createTimeCounter(date, toilet_id, in_use){ 2 | if (date){ 3 | // Initially countdown is set to avoid the interval delay 4 | $("#toilet-counter-" + toilet_id).text(countdown(date).toString()); 5 | return setInterval(function () { 6 | count = countdown(date); 7 | $("#toilet-counter-" + toilet_id).text(count.toString()); 8 | total_time = 0; 9 | $("#toilet-" + toilet_id).attr("class", !in_use); 10 | 11 | if (in_use) { 12 | total_time = count.minutes*60 + count.seconds; 13 | $("#toilet-" + toilet_id).attr("class", !in_use); 14 | if (total_time > 60*2) { 15 | $("#toilet-" + toilet_id).attr("class", "poo"); 16 | } 17 | if (total_time > 60*10) { 18 | $("#toilet-" + toilet_id).attr("class", "dead"); 19 | } 20 | } 21 | }, 1000); 22 | } 23 | } 24 | 25 | $(function () { 26 | var time_counters = []; 27 | 28 | var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws"; 29 | var ws_host = $("[data-ws-host]").data("ws-host") != "" ? $("[data-ws-host]").data("ws-host") : window.location.host; 30 | var ws_path = ws_scheme + '://' + ws_host + "/stream/"; 31 | console.log("Connecting to " + ws_path); 32 | var socket = new ReconnectingWebSocket(ws_path); 33 | 34 | // Set favicon animation 35 | var favicon = new Favico({bgColor : '#000000', textColor : '#FFFFFF', animation: 'slide'}); 36 | var favicon_free = document.getElementById("favicon-free"); 37 | var favicon_using = document.getElementById("favicon-using"); 38 | 39 | socket.onmessage = function(message) { 40 | console.log("Got message " + message.data); 41 | var data = JSON.parse(message.data); 42 | var badge = $("[data-toilets-count]").data("toilets-count"); 43 | 44 | // Set a time counter for each toilet 45 | $.each(data, function(index, item){ 46 | $("#toilet-counter-" + item.toilet_id).attr("class", !item.in_use); 47 | clearTimeout(time_counters[item.toilet_id]); 48 | 49 | // If the toilet is free, show the last usage time 50 | if (!item.in_use){ 51 | time_counters[item.toilet_id] = createTimeCounter(new Date(item.end_at), item.toilet_id, item.in_use); 52 | badge = (badge-1 < 0) ? 0 : (badge - 1); 53 | $.get('toilet/' + item.toilet_id +'/last_usage_time', function(data){ 54 | $("#toilet-last-usage-" + item.toilet_id).show(); 55 | total_time = moment.duration(data.usage_time, "seconds").humanize(); 56 | $("#toilet-last-usage-time-" + item.toilet_id).text(total_time); 57 | }); 58 | }else{ 59 | time_counters[item.toilet_id] = createTimeCounter(new Date(item.start_at), item.toilet_id, item.in_use); 60 | $("#toilet-last-usage-" + item.toilet_id).hide(); 61 | } 62 | }); 63 | if (badge == 0){ 64 | favicon.image(favicon_free); 65 | }else{ 66 | favicon.image(favicon_using); 67 | favicon.badge(badge); 68 | // If favicon image was changed badge has to be set twice 69 | // in order to view the correct number in the favicon badge. 70 | favicon.badge(badge); 71 | } 72 | }; 73 | // Helpful debugging 74 | socket.onopen = function() { 75 | console.log("Connected to notification socket"); 76 | $.get('toilets/last_event') 77 | }; 78 | socket.onclose = function() { 79 | console.log("Disconnected to notification socket"); 80 | }; 81 | }); 82 | -------------------------------------------------------------------------------- /server/main/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from configurations import Configuration 4 | from django.contrib.messages import constants as messages 5 | from kaio import Options 6 | from kaio.mixins import CachesMixin, DatabasesMixin, LogsMixin, PathsMixin, SecurityMixin, DebugMixin, WhiteNoiseMixin 7 | 8 | 9 | opts = Options() 10 | 11 | 12 | class Base(CachesMixin, DatabasesMixin, PathsMixin, LogsMixin, SecurityMixin, DebugMixin, 13 | WhiteNoiseMixin, Configuration): 14 | """ 15 | Project settings for development and production. 16 | """ 17 | 18 | DEBUG = opts.get('DEBUG', True) 19 | 20 | BASE_DIR = opts.get('APP_ROOT', None) 21 | APP_SLUG = opts.get('APP_SLUG', 'tenerife_japon_status') 22 | SITE_ID = 1 23 | SECRET_KEY = opts.get('SECRET_KEY', 'key') 24 | 25 | USE_I18N = True 26 | USE_L10N = True 27 | USE_TZ = True 28 | LANGUAGE_CODE = 'es' 29 | TIME_ZONE = 'Europe/Madrid' 30 | 31 | ROOT_URLCONF = 'main.urls' 32 | WSGI_APPLICATION = 'main.wsgi.application' 33 | 34 | INSTALLED_APPS = [ 35 | # django 36 | 'django.contrib.admin', 37 | 'django.contrib.auth', 38 | 'django.contrib.contenttypes', 39 | 'django.contrib.sessions', 40 | 'django.contrib.sites', 41 | 'django.contrib.messages', 42 | 'django.contrib.staticfiles', 43 | 'django.contrib.humanize', 44 | 45 | # apps 46 | 'main', 47 | 'toilet', 48 | 'stats', 49 | 50 | # 3rd parties 51 | 'raven.contrib.django.raven_compat', 52 | 'django_extensions', 53 | 'django_yubin', 54 | 'kaio', 55 | 'logentry_admin', 56 | 'channels', 57 | 'timedeltatemplatefilter', 58 | ] 59 | 60 | MIDDLEWARE = [ 61 | 'django.middleware.security.SecurityMiddleware', 62 | 'django.middleware.locale.LocaleMiddleware', 63 | 'django.contrib.sessions.middleware.SessionMiddleware', 64 | 'django.middleware.common.CommonMiddleware', 65 | 'django.middleware.csrf.CsrfViewMiddleware', 66 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 67 | 'django.contrib.messages.middleware.MessageMiddleware', 68 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 69 | ] 70 | 71 | # SecurityMiddleware options 72 | SECURE_BROWSER_XSS_FILTER = True 73 | 74 | TEMPLATES = [ 75 | { 76 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 77 | 'DIRS': [ 78 | # insert additional TEMPLATE_DIRS here 79 | ], 80 | 'OPTIONS': { 81 | 'context_processors': [ 82 | "django.contrib.auth.context_processors.auth", 83 | "django.template.context_processors.debug", 84 | "django.template.context_processors.i18n", 85 | "django.template.context_processors.media", 86 | "django.template.context_processors.static", 87 | "django.contrib.messages.context_processors.messages", 88 | "django.template.context_processors.tz", 89 | 'django.template.context_processors.request', 90 | 'constance.context_processors.config', 91 | ], 92 | 'loaders': [ 93 | 'django.template.loaders.filesystem.Loader', 94 | 'django.template.loaders.app_directories.Loader', 95 | ] 96 | }, 97 | }, 98 | ] 99 | if not DEBUG: 100 | TEMPLATES[0]['OPTIONS']['loaders'] = [ 101 | ('django.template.loaders.cached.Loader', TEMPLATES[0]['OPTIONS']['loaders']), 102 | ] 103 | 104 | # Bootstrap 3 alerts integration with Django messages 105 | MESSAGE_TAGS = { 106 | messages.ERROR: 'danger', 107 | } 108 | 109 | # Channels 110 | if DEBUG: 111 | CHANNEL_LAYERS = { 112 | "default": { 113 | "BACKEND": "asgiref.inmemory.ChannelLayer", 114 | "ROUTING": "toilet.routing.channel_routing", 115 | }, 116 | } 117 | else: 118 | CHANNEL_LAYERS = { 119 | "default": { 120 | "BACKEND": "asgi_redis.RedisChannelLayer", 121 | "CONFIG": { 122 | "hosts": ["redis://redis:6379/10"], 123 | }, 124 | "ROUTING": "toilet.routing.channel_routing", 125 | }, 126 | } 127 | 128 | # Web Sockets 129 | WEB_SOCKET_HOST = opts.get('WEB_SOCKET_HOST', '') 130 | -------------------------------------------------------------------------------- /server/main/static/main/js/countdown.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | countdown.js v2.6.0 http://countdownjs.org 3 | Copyright (c)2006-2014 Stephen M. McKamey. 4 | Licensed under The MIT License. 5 | */ 6 | var module,countdown=function(v){function A(a,b){var c=a.getTime();a.setMonth(a.getMonth()+b);return Math.round((a.getTime()-c)/864E5)}function w(a){var b=a.getTime(),c=new Date(b);c.setMonth(a.getMonth()+1);return Math.round((c.getTime()-b)/864E5)}function x(a,b){b=b instanceof Date||null!==b&&isFinite(b)?new Date(+b):new Date;if(!a)return b;var c=+a.value||0;if(c)return b.setTime(b.getTime()+c),b;(c=+a.milliseconds||0)&&b.setMilliseconds(b.getMilliseconds()+c);(c=+a.seconds||0)&&b.setSeconds(b.getSeconds()+ 7 | c);(c=+a.minutes||0)&&b.setMinutes(b.getMinutes()+c);(c=+a.hours||0)&&b.setHours(b.getHours()+c);(c=+a.weeks||0)&&(c*=7);(c+=+a.days||0)&&b.setDate(b.getDate()+c);(c=+a.months||0)&&b.setMonth(b.getMonth()+c);(c=+a.millennia||0)&&(c*=10);(c+=+a.centuries||0)&&(c*=10);(c+=+a.decades||0)&&(c*=10);(c+=+a.years||0)&&b.setFullYear(b.getFullYear()+c);return b}function D(a,b){return y(a)+(1===a?p[b]:q[b])}function n(){}function k(a,b,c,e,l,d){0<=a[c]&&(b+=a[c],delete a[c]);b/=l;if(1>=b+1)return 0;if(0<=a[e]){a[e]= 8 | +(a[e]+b).toFixed(d);switch(e){case "seconds":if(60!==a.seconds||isNaN(a.minutes))break;a.minutes++;a.seconds=0;case "minutes":if(60!==a.minutes||isNaN(a.hours))break;a.hours++;a.minutes=0;case "hours":if(24!==a.hours||isNaN(a.days))break;a.days++;a.hours=0;case "days":if(7!==a.days||isNaN(a.weeks))break;a.weeks++;a.days=0;case "weeks":if(a.weeks!==w(a.refMonth)/7||isNaN(a.months))break;a.months++;a.weeks=0;case "months":if(12!==a.months||isNaN(a.years))break;a.years++;a.months=0;case "years":if(10!== 9 | a.years||isNaN(a.decades))break;a.decades++;a.years=0;case "decades":if(10!==a.decades||isNaN(a.centuries))break;a.centuries++;a.decades=0;case "centuries":if(10!==a.centuries||isNaN(a.millennia))break;a.millennia++;a.centuries=0}return 0}return b}function B(a,b,c,e,l,d){var f=new Date;a.start=b=b||f;a.end=c=c||f;a.units=e;a.value=c.getTime()-b.getTime();0>a.value&&(f=c,c=b,b=f);a.refMonth=new Date(b.getFullYear(),b.getMonth(),15,12,0,0);try{a.millennia=0;a.centuries=0;a.decades=0;a.years=c.getFullYear()- 10 | b.getFullYear();a.months=c.getMonth()-b.getMonth();a.weeks=0;a.days=c.getDate()-b.getDate();a.hours=c.getHours()-b.getHours();a.minutes=c.getMinutes()-b.getMinutes();a.seconds=c.getSeconds()-b.getSeconds();a.milliseconds=c.getMilliseconds()-b.getMilliseconds();var g;0>a.milliseconds?(g=s(-a.milliseconds/1E3),a.seconds-=g,a.milliseconds+=1E3*g):1E3<=a.milliseconds&&(a.seconds+=m(a.milliseconds/1E3),a.milliseconds%=1E3);0>a.seconds?(g=s(-a.seconds/60),a.minutes-=g,a.seconds+=60*g):60<=a.seconds&&(a.minutes+= 11 | m(a.seconds/60),a.seconds%=60);0>a.minutes?(g=s(-a.minutes/60),a.hours-=g,a.minutes+=60*g):60<=a.minutes&&(a.hours+=m(a.minutes/60),a.minutes%=60);0>a.hours?(g=s(-a.hours/24),a.days-=g,a.hours+=24*g):24<=a.hours&&(a.days+=m(a.hours/24),a.hours%=24);for(;0>a.days;)a.months--,a.days+=A(a.refMonth,1);7<=a.days&&(a.weeks+=m(a.days/7),a.days%=7);0>a.months?(g=s(-a.months/12),a.years-=g,a.months+=12*g):12<=a.months&&(a.years+=m(a.months/12),a.months%=12);10<=a.years&&(a.decades+=m(a.years/10),a.years%= 12 | 10,10<=a.decades&&(a.centuries+=m(a.decades/10),a.decades%=10,10<=a.centuries&&(a.millennia+=m(a.centuries/10),a.centuries%=10)));b=0;!(e&1024)||b>=l?(a.centuries+=10*a.millennia,delete a.millennia):a.millennia&&b++;!(e&512)||b>=l?(a.decades+=10*a.centuries,delete a.centuries):a.centuries&&b++;!(e&256)||b>=l?(a.years+=10*a.decades,delete a.decades):a.decades&&b++;!(e&128)||b>=l?(a.months+=12*a.years,delete a.years):a.years&&b++;!(e&64)||b>=l?(a.months&&(a.days+=A(a.refMonth,a.months)),delete a.months, 13 | 7<=a.days&&(a.weeks+=m(a.days/7),a.days%=7)):a.months&&b++;!(e&32)||b>=l?(a.days+=7*a.weeks,delete a.weeks):a.weeks&&b++;!(e&16)||b>=l?(a.hours+=24*a.days,delete a.days):a.days&&b++;!(e&8)||b>=l?(a.minutes+=60*a.hours,delete a.hours):a.hours&&b++;!(e&4)||b>=l?(a.seconds+=60*a.minutes,delete a.minutes):a.minutes&&b++;!(e&2)||b>=l?(a.milliseconds+=1E3*a.seconds,delete a.seconds):a.seconds&&b++;if(!(e&1)||b>=l){var h=k(a,0,"milliseconds","seconds",1E3,d);if(h&&(h=k(a,h,"seconds","minutes",60,d))&&(h= 14 | k(a,h,"minutes","hours",60,d))&&(h=k(a,h,"hours","days",24,d))&&(h=k(a,h,"days","weeks",7,d))&&(h=k(a,h,"weeks","months",w(a.refMonth)/7,d))){e=h;var n,p=a.refMonth,q=p.getTime(),r=new Date(q);r.setFullYear(p.getFullYear()+1);n=Math.round((r.getTime()-q)/864E5);if(h=k(a,e,"months","years",n/w(a.refMonth),d))if(h=k(a,h,"years","decades",10,d))if(h=k(a,h,"decades","centuries",10,d))if(h=k(a,h,"centuries","millennia",10,d))throw Error("Fractional unit overflow");}}}finally{delete a.refMonth}return a} 15 | function d(a,b,c,e,d){var f;c=+c||222;e=0d?Math.round(d):20:0;var k=null;"function"===typeof a?(f=a,a=null):a instanceof Date||(null!==a&&isFinite(a)?a=new Date(+a):("object"===typeof k&&(k=a),a=null));var g=null;"function"===typeof b?(f=b,b=null):b instanceof Date||(null!==b&&isFinite(b)?b=new Date(+b):("object"===typeof b&&(g=b),b=null));k&&(a=x(k,b));g&&(b=x(g,a));if(!a&&!b)return new n;if(!f)return B(new n,a,b,c,e,d);var k=c&1?1E3/30:c&2?1E3:c&4?6E4:c&8?36E5:c&16?864E5:6048E5, 16 | h,g=function(){f(B(new n,a,b,c,e,d),h)};g();return h=setInterval(g,k)}var s=Math.ceil,m=Math.floor,p,q,r,t,u,f,y,z;n.prototype.toString=function(a){var b=z(this),c=b.length;if(!c)return a?""+a:u;if(1===c)return b[0];a=r+b.pop();return b.join(t)+a};n.prototype.toHTML=function(a,b){a=a||"span";var c=z(this),e=c.length;if(!e)return(b=b||u)?"\x3c"+a+"\x3e"+b+"\x3c/"+a+"\x3e":b;for(var d=0;d=d;d++)p[d]=b[d]||p[d],q[d]=c[d]||q[d]}"string"===typeof a.last&&(r=a.last);"string"===typeof a.delim&&(t=a.delim);"string"===typeof a.empty&&(u=a.empty);"function"===typeof a.formatNumber&&(y=a.formatNumber);"function"===typeof a.formatter&&(f=a.formatter)}},C=d.resetFormat= 19 | function(){p=" millisecond; second; minute; hour; day; week; month; year; decade; century; millennium".split(";");q=" milliseconds; seconds; minutes; hours; days; weeks; months; years; decades; centuries; millennia".split(";");r=" and ";t=", ";u="";y=function(a){return a};f=D};d.setLabels=function(a,b,c,d,f,k,m){E({singular:a,plural:b,last:c,delim:d,empty:f,formatNumber:k,formatter:m})};d.resetLabels=C;C();v&&v.exports?v.exports=d:"function"===typeof window.define&&"undefined"!==typeof window.define.amd&& 20 | window.define("countdown",[],function(){return d});return d}(module); -------------------------------------------------------------------------------- /server/main/static/main/css/toilet.less: -------------------------------------------------------------------------------- 1 | @first:#333; 2 | @second:#666666; 3 | @success:#3c763d; 4 | @error:#a94442; 5 | .rounded(@value:5px) { 6 | -webkit-border-radius: @value; 7 | -moz-border-radius: @value; 8 | border-radius: @value; 9 | } 10 | .gradient (@origin: top, @start:#a7880a, @stop:#3a3110) { 11 | background-color: @start; 12 | background-image: -webkit-linear-gradient(@origin, @start, @stop); 13 | background-image: -moz-linear-gradient(@origin, @start, @stop); 14 | background-image: -o-linear-gradient(@origin, @start, @stop); 15 | background-image: -ms-linear-gradient(@origin, @start, @stop); 16 | background-image: linear-gradient(@origin, @start, @stop); 17 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=@stop, endColorstr=@stop, GradientType=0); 18 | } 19 | .box-shadow (@x: 0px, @y: 2px, @blur: 10px, @alpha: 0.5) { 20 | -webkit-box-shadow: @x @y @blur rgba(0, 0, 0, @alpha); 21 | -moz-box-shadow: @x @y @blur rgba(0, 0, 0, @alpha); 22 | box-shadow: @x @y @blur rgba(0, 0, 0, @alpha); 23 | } 24 | .noMargin {margin:0;} 25 | .relative {position:relative;} 26 | .overflow {overflow:hidden;} 27 | .clear {clear:both;} 28 | .btn { 29 | .rounded(8px); 30 | text-transform:uppercase; 31 | } 32 | textarea {height:60px;} 33 | img {max-width:100%;} 34 | a { 35 | color:@first; 36 | outline:none; 37 | transition: all 0.8s ease 0s; 38 | &:hover, 39 | &:active, 40 | &:focus { 41 | text-decoration:none; 42 | outline:none; 43 | } 44 | } 45 | .well { 46 | .rounded(0); 47 | border:0; 48 | box-shadow:inherit; 49 | min-height: 365px; 50 | } 51 | 52 | html {height:100%;} 53 | body { 54 | height:auto; 55 | background:lighten(spin(@second, 8%), 90%); 56 | color:darken(spin(@second, 8%), 25%); 57 | font-family:'Marcellus', serif; 58 | } 59 | h1, h2, h3, h4, h5, h6 { 60 | color:darken(spin(@second, 8%), 25%); 61 | font-family:'Marcellus', serif; 62 | font-weight:400; 63 | font-size:1.5em; 64 | margin-top:0; 65 | } 66 | .btn-corporate { 67 | font-family:'Marcellus', serif; 68 | text-transform:uppercase; 69 | background:@first; 70 | font-size:1.1em; 71 | padding:8px 20px; 72 | margin-top:7px; 73 | letter-spacing:1.5px; 74 | color:lighten(spin(@first, 8%), 90%); 75 | transition: all 0.8s ease 0s; 76 | &:hover { 77 | background:darken(spin(@first, 8%), 10%); 78 | color:lighten(spin(@first, 8%), 90%); 79 | } 80 | } 81 | header { 82 | background:lighten(spin(@second, 8%), 56%) url(../img/bg.jpg); 83 | padding-bottom:25px; 84 | border-bottom:1px dashed lighten(spin(@second, 8%), 40%); 85 | margin-bottom:50px; 86 | .navbar { 87 | background:lighten(spin(@second, 8%), 90%); 88 | box-shadow:0 0px 5px 0 @second; 89 | font-family:'Marcellus', serif; 90 | .rounded(0); 91 | min-height:40px; 92 | margin-bottom:25px; 93 | .navbar-brand { 94 | padding-top:10px; 95 | } 96 | .navbar-toggle { 97 | margin-bottom:35px; 98 | margin-top:30px; 99 | } 100 | .navbar-nav { 101 | font-size:1.1em; 102 | li { 103 | a { 104 | background:transparent; 105 | letter-spacing:1px; 106 | color:darken(spin(@second, 8%), 25%); 107 | padding-top:10px; 108 | padding-bottom:10px; 109 | transition: all 0.8s ease 0s; 110 | &:hover {color:lighten(spin(@second, 8%), 25%);} 111 | &.btn { 112 | text-transform:uppercase; 113 | background:@first; 114 | font-size:1em; 115 | padding:8px 20px; 116 | margin-top:7px; 117 | letter-spacing:1.5px; 118 | color:lighten(spin(@first, 8%), 90%); 119 | &:hover {background:darken(spin(@first, 8%), 20%);} 120 | } 121 | } 122 | } 123 | } 124 | } 125 | img {max-width:300px;} 126 | h2 { 127 | font-size:3em; 128 | margin-top:30px; 129 | span { 130 | display:inline-block; 131 | margin-top:-30px; 132 | font-size:1.8em; 133 | font-weight:bold; 134 | transform:rotate(-9deg); 135 | } 136 | } 137 | } 138 | 139 | section { 140 | .nav-tabs { 141 | border:0; 142 | li { 143 | &.active { 144 | a, 145 | a:hover{ 146 | background:#f5f5f5; 147 | border:0; 148 | margin-bottom:0; 149 | .rounded(0); 150 | } 151 | } 152 | } 153 | } 154 | .filterList { 155 | li { 156 | border-bottom:2px solid lighten(spin(@second, 8%), 90%); 157 | &.active { 158 | border-color:@success; 159 | } 160 | } 161 | } 162 | .well { 163 | background:lighten(spin(@second, 8%), 56%) url(../img/bg.jpg); 164 | border:1px dashed lighten(spin(@second, 8%), 40%); 165 | &.wellStats { 166 | border:0; 167 | min-height:auto; 168 | ul { 169 | li { 170 | font-size:1.3em; 171 | color:@success; 172 | span { 173 | color:@first; 174 | font-size:0.9em; 175 | } 176 | } 177 | } 178 | } 179 | p { 180 | display:block; 181 | position:relative; 182 | min-height:220px; 183 | text-indent:-9999em; 184 | background:url(../img/toilet.png) no-repeat top center; 185 | &.true { 186 | &:before { 187 | display:block; 188 | background:url(../img/ok.png) no-repeat; 189 | background-size:cover; 190 | width:100px; 191 | height:100px; 192 | content:""; 193 | position:absolute; 194 | left:145px; 195 | top:-10px; 196 | } 197 | } 198 | &.false { 199 | &:before { 200 | display:block; 201 | background:url(../img/pee.png) no-repeat; 202 | background-size:cover; 203 | width:70px; 204 | height:70px; 205 | content:""; 206 | position:absolute; 207 | left:150px; 208 | margin-top:20px; 209 | animation: moving linear 3s; 210 | animation-iteration-count: infinite; 211 | } 212 | } 213 | &.poo { 214 | &:before { 215 | display:block; 216 | background:url(../img/poop.png) no-repeat; 217 | background-size:cover; 218 | width:70px; 219 | height:70px; 220 | content:""; 221 | position:absolute; 222 | left:150px; 223 | margin-top:20px; 224 | animation: moving linear 3s; 225 | animation-iteration-count: infinite; 226 | } 227 | } 228 | &.dead { 229 | &:before { 230 | display:block; 231 | background:url(../img/dead.png) no-repeat; 232 | background-size:cover; 233 | width:70px; 234 | height:70px; 235 | content:""; 236 | position:absolute; 237 | left:150px; 238 | margin-top:20px; 239 | animation: moving linear 3s; 240 | animation-iteration-count: infinite; 241 | } 242 | } 243 | } 244 | h4{ 245 | &.true {color:@success;} 246 | &.false {color:@error;} 247 | } 248 | } 249 | } 250 | 251 | @keyframes moving { 252 | 0% { 253 | top:-20px; 254 | } 255 | 25% { 256 | top:0; 257 | } 258 | 50% { 259 | top:-20px; 260 | } 261 | 75% { 262 | top:0; 263 | } 264 | 100% { 265 | top:-20px; 266 | } 267 | } 268 | 269 | @media (max-width: 1200px) { 270 | section .well p.true::before {left:110px;} 271 | section .well p.false::before {left:120px;} 272 | } 273 | 274 | @media (max-width: 991px) { 275 | section .well p.true::before {left:130px;} 276 | section .well p.false::before {left:145px;} 277 | } 278 | 279 | @media (max-width: 768px) { 280 | .nav > li {display:inline-block;} 281 | section .well p.true::before {left:45%;} 282 | section .well p.false::before {left:47%;} 283 | } 284 | 285 | @media (max-width: 495px) { 286 | header h2 {font-size:2em;} 287 | } 288 | -------------------------------------------------------------------------------- /server/main/static/main/js/less-1.3.1.min.js: -------------------------------------------------------------------------------- 1 | // 2 | // LESS - Leaner CSS v1.3.1 3 | // http://lesscss.org 4 | // 5 | // Copyright (c) 2009-2011, Alexis Sellier 6 | // Licensed under the Apache 2.0 License. 7 | // 8 | (function(e,t){function n(t){return e.less[t.split("/")[1]]}function h(){var e=document.getElementsByTagName("style");for(var t=0;t0?r.firstChild.nodeValue!==e.nodeValue&&r.replaceChild(e,r.firstChild):r.appendChild(e)})(document.createTextNode(e));if(n&&u){w("saving "+i+" to cache.");try{u.setItem(i,e),u.setItem(i+":timestamp",n)}catch(a){w("failed to save")}}}function g(e,t,n,i){function a(t,n,r){t.status>=200&&t.status<300?n(t.responseText,t.getResponseHeader("Last-Modified")):typeof r=="function"&&r(t.status,e)}var o=y(),u=s?r.fileAsync:r.async;typeof o.overrideMimeType=="function"&&o.overrideMimeType("text/css"),o.open("GET",e,u),o.setRequestHeader("Accept",t||"text/x-less, text/css; q=0.9, */*; q=0.5"),o.send(null),s&&!r.fileAsync?o.status===0||o.status>=200&&o.status<300?n(o.responseText):i(o.status,e):u?o.onreadystatechange=function(){o.readyState==4&&a(o,n,i)}:a(o,n,i)}function y(){if(e.XMLHttpRequest)return new XMLHttpRequest;try{return new ActiveXObject("MSXML2.XMLHTTP.3.0")}catch(t){return w("browser doesn't support AJAX."),null}}function b(e){return e&&e.parentNode.removeChild(e)}function w(e){r.env=="development"&&typeof console!="undefined"&&console.log("less: "+e)}function E(e,t){var n="less-error-message:"+v(t),i='
  • {content}
  • ',s=document.createElement("div"),o,u,a=[],f=e.filename||t,l=f.match(/([^\/]+)$/)[1];s.id=n,s.className="less-error-message",u="

    "+(e.message||"There is an error in your .less file")+"

    "+'

    in '+l+" ";var c=function(e,t,n){e.extract[t]&&a.push(i.replace(/\{line\}/,parseInt(e.line)+(t-1)).replace(/\{class\}/,n).replace(/\{content\}/,e.extract[t]))};e.stack?u+="
    "+e.stack.split("\n").slice(1).join("
    "):e.extract&&(c(e,0,""),c(e,1,"line"),c(e,2,""),u+="on line "+e.line+", column "+(e.column+1)+":

    "+"
      "+a.join("")+"
    "),s.innerHTML=u,m([".less-error-message ul, .less-error-message li {","list-style-type: none;","margin-right: 15px;","padding: 4px 0;","margin: 0;","}",".less-error-message label {","font-size: 12px;","margin-right: 15px;","padding: 4px 0;","color: #cc7777;","}",".less-error-message pre {","color: #dd6666;","padding: 4px 0;","margin: 0;","display: inline-block;","}",".less-error-message pre.line {","color: #ff0000;","}",".less-error-message h3 {","font-size: 20px;","font-weight: bold;","padding: 15px 0 5px 0;","margin: 0;","}",".less-error-message a {","color: #10a","}",".less-error-message .error {","color: red;","font-weight: bold;","padding-bottom: 2px;","border-bottom: 1px dashed red;","}"].join("\n"),{title:"error-message"}),s.style.cssText=["font-family: Arial, sans-serif","border: 1px solid #e00","background-color: #eee","border-radius: 5px","-webkit-border-radius: 5px","-moz-border-radius: 5px","color: #e00","padding: 15px","margin-bottom: 15px"].join(";"),r.env=="development"&&(o=setInterval(function(){document.body&&(document.getElementById(n)?document.body.replaceChild(s,document.getElementById(n)):document.body.insertBefore(s,document.body.firstChild),clearInterval(o))},10))}Array.isArray||(Array.isArray=function(e){return Object.prototype.toString.call(e)==="[object Array]"||e instanceof Array}),Array.prototype.forEach||(Array.prototype.forEach=function(e,t){var n=this.length>>>0;for(var r=0;r>>0,n=new Array(t),r=arguments[1];for(var i=0;i>>0,n=0;if(t===0&&arguments.length===1)throw new TypeError;if(arguments.length>=2)var r=arguments[1];else do{if(n in this){r=this[n++];break}if(++n>=t)throw new TypeError}while(!0);for(;n=t)return-1;n<0&&(n+=t);for(;nh&&(c[u]=c[u].slice(o-h),h=o)}function w(e){var t=e.charCodeAt(0);return t===32||t===10||t===9}function E(e){var t,n,r,i,a;if(e instanceof Function)return e.call(p.parsers);if(typeof e=="string")t=s.charAt(o)===e?e:null,r=1,b();else{b();if(!(t=e.exec(c[u])))return null;r=t[0].length}if(t)return S(r),typeof t=="string"?t:t.length===1?t[0]:t}function S(e){var t=o,n=u,r=o+c[u].length,i=o+=e;while(o=0&&t.charAt(n)!=="\n";n--)r++;return{line:typeof e=="number"?(t.slice(0,e).match(/\n/g)||"").length:null,column:r}}function L(e){return r.mode==="browser"||r.mode==="rhino"?e.filename:n("path").resolve(e.filename)}function A(e,t,n){return{lineNumber:k(e,t).line+1,fileName:L(n)}}function O(e,t){var n=C(e,t),r=k(e.index,n),i=r.line,s=r.column,o=n.split("\n");this.type=e.type||"Syntax",this.message=e.message,this.filename=e.filename||t.filename,this.index=e.index,this.line=typeof i=="number"?i+1:null,this.callLine=e.call&&k(e.call,n).line+1,this.callExtract=o[k(e.call,n).line],this.stack=e.stack,this.column=s,this.extract=[o[i-1],o[i],o[i+1]]}var s,o,u,a,f,l,c,h,p,d=this,t=t||{};t.contents||(t.contents={});var v=function(){},m=this.imports={paths:t&&t.paths||[],queue:[],files:{},contents:t.contents,mime:t&&t.mime,error:null,push:function(e,n){var i=this;this.queue.push(e),r.Parser.importer(e,this.paths,function(t,r){i.queue.splice(i.queue.indexOf(e),1);var s=e in i.files;i.files[e]=r,t&&!i.error&&(i.error=t),n(t,r,s),i.queue.length===0&&v(t)},t)}};return this.env=t=t||{},this.optimization="optimization"in this.env?this.env.optimization:1,this.env.filename=this.env.filename||null,p={imports:m,parse:function(e,a){var f,d,m,g,y,b,w=[],S,x=null;o=u=h=l=0,s=e.replace(/\r\n/g,"\n"),s=s.replace(/^\uFEFF/,""),c=function(e){var n=0,r=/(?:@\{[\w-]+\}|[^"'`\{\}\/\(\)\\])+/g,i=/\/\*(?:[^*]|\*+[^\/*])*\*+\/|\/\/.*/g,o=/"((?:[^"\\\r\n]|\\.)*)"|'((?:[^'\\\r\n]|\\.)*)'|`((?:[^`]|\\.)*)`/g,u=0,a,f=e[0],l;for(var c=0,h,p;c0&&(x=new O({index:c,type:"Parse",message:"missing closing `}`",filename:t.filename},t)),e.map(function(e){return e.join("")})}([[]]);if(x)return a(x);try{f=new i.Ruleset([],E(this.parsers.primary)),f.root=!0}catch(T){return a(new O(T,t))}f.toCSS=function(e){var s,o,u;return function(s,o){var u=[],a;s=s||{},typeof o=="object"&&!Array.isArray(o)&&(o=Object.keys(o).map(function(e){var t=o[e];return t instanceof i.Value||(t instanceof i.Expression||(t=new i.Expression([t])),t=new i.Value([t])),new i.Rule("@"+e,t,!1,0)}),u=[new i.Ruleset(null,o)]);try{var f=e.call(this,{frames:u}).toCSS([],{compress:s.compress||!1,dumpLineNumbers:t.dumpLineNumbers})}catch(l){throw new O(l,t)}if(a=p.imports.error)throw a instanceof O?a:new O(a,t);return s.yuicompress&&r.mode==="node"?n("./cssmin").compressor.cssmin(f):s.compress?f.replace(/(\s)+/g,"$1"):f}}(f.eval);if(o=0&&s.charAt(N)!=="\n";N--)C++;x={type:"Parse",message:"Syntax Error on line "+y,index:o,filename:t.filename,line:y,column:C,extract:[b[y-2],b[y-1],b[y]]}}this.imports.queue.length>0?v=function(e){e?a(e):a(null,f)}:a(x,f)},parsers:{primary:function(){var e,t=[];while((e=E(this.mixin.definition)||E(this.rule)||E(this.ruleset)||E(this.mixin.call)||E(this.comment)||E(this.directive))||E(/^[\s\n]+/))e&&t.push(e);return t},comment:function(){var e;if(s.charAt(o)!=="/")return;if(s.charAt(o+1)==="/")return new i.Comment(E(/^\/\/.*/),!0);if(e=E(/^\/\*(?:[^*]|\*+[^\/*])*\*+\/\n?/))return new i.Comment(e)},entities:{quoted:function(){var e,t=o,n;s.charAt(t)==="~"&&(t++,n=!0);if(s.charAt(t)!=='"'&&s.charAt(t)!=="'")return;n&&E("~");if(e=E(/^"((?:[^"\\\r\n]|\\.)*)"|'((?:[^'\\\r\n]|\\.)*)'/))return new i.Quoted(e[0],e[1]||e[2],n)},keyword:function(){var e;if(e=E(/^[_A-Za-z-][_A-Za-z0-9-]*/))return i.colors.hasOwnProperty(e)?new i.Color(i.colors[e].slice(1)):new i.Keyword(e)},call:function(){var e,n,r,s,a=o;if(!(e=/^([\w-]+|%|progid:[\w\.]+)\(/.exec(c[u])))return;e=e[1],n=e.toLowerCase();if(n==="url")return null;o+=e.length;if(n==="alpha"){s=E(this.alpha);if(typeof s!="undefined")return s}E("("),r=E(this.entities.arguments);if(!E(")"))return;if(e)return new i.Call(e,r,a,t.filename)},arguments:function(){var e=[],t;while(t=E(this.entities.assignment)||E(this.expression)){e.push(t);if(!E(","))break}return e},literal:function(){return E(this.entities.ratio)||E(this.entities.dimension)||E(this.entities.color)||E(this.entities.quoted)},assignment:function(){var e,t;if((e=E(/^\w+(?=\s?=)/i))&&E("=")&&(t=E(this.entity)))return new i.Assignment(e,t)},url:function(){var e;if(s.charAt(o)!=="u"||!E(/^url\(/))return;return e=E(this.entities.quoted)||E(this.entities.variable)||E(/^(?:(?:\\[\(\)'"])|[^\(\)'"])+/)||"",x(")"),new i.URL(e.value!=null||e instanceof i.Variable?e:new i.Anonymous(e),m.paths)},variable:function(){var e,n=o;if(s.charAt(o)==="@"&&(e=E(/^@@?[\w-]+/)))return new i.Variable(e,n,t.filename)},variableCurly:function(){var e,n,r=o;if(s.charAt(o)==="@"&&(n=E(/^@\{([\w-]+)\}/)))return new i.Variable("@"+n[1],r,t.filename)},color:function(){var e;if(s.charAt(o)==="#"&&(e=E(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})/)))return new i.Color(e[1])},dimension:function(){var e,t=s.charCodeAt(o);if(t>57||t<45||t===47)return;if(e=E(/^(-?\d*\.?\d+)(px|%|em|pc|ex|in|deg|s|ms|pt|cm|mm|rad|grad|turn|dpi|dpcm|dppx|rem|vw|vh|vmin|vm|ch)?/))return new i.Dimension(e[1],e[2])},ratio:function(){var e,t=s.charCodeAt(o);if(t>57||t<48)return;if(e=E(/^(\d+\/\d+)/))return new i.Ratio(e[1])},javascript:function(){var e,t=o,n;s.charAt(t)==="~"&&(t++,n=!0);if(s.charAt(t)!=="`")return;n&&E("~");if(e=E(/^`([^`]*)`/))return new i.JavaScript(e[1],o,n)}},variable:function(){var e;if(s.charAt(o)==="@"&&(e=E(/^(@[\w-]+)\s*:/)))return e[1]},shorthand:function(){var e,t;if(!N(/^[@\w.%-]+\/[@\w.-]+/))return;g();if((e=E(this.entity))&&E("/")&&(t=E(this.entity)))return new i.Shorthand(e,t);y()},mixin:{call:function(){var e=[],n,r,u=[],a,f=o,l=s.charAt(o),c,h,p=!1;if(l!=="."&&l!=="#")return;g();while(n=E(/^[#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/))e.push(new i.Element(r,n,o)),r=E(">");if(E("(")){while(a=E(this.expression)){h=a,c=null;if(a.value.length==1){var d=a.value[0];if(d instanceof i.Variable&&E(":")){if(!(h=E(this.expression)))throw new Error("Expected value");c=d.name}}u.push({name:c,value:h});if(!E(","))break}if(!E(")"))throw new Error("Expected )")}E(this.important)&&(p=!0);if(e.length>0&&(E(";")||N("}")))return new i.mixin.Call(e,u,f,t.filename,p);y()},definition:function(){var e,t=[],n,r,u,a,f,c=!1;if(s.charAt(o)!=="."&&s.charAt(o)!=="#"||N(/^[^{]*(;|})/))return;g();if(n=E(/^([#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+)\s*\(/)){e=n[1];do{if(s.charAt(o)==="."&&E(/^\.{3}/)){c=!0;break}if(!(u=E(this.entities.variable)||E(this.entities.literal)||E(this.entities.keyword)))break;if(u instanceof i.Variable)if(E(":"))a=x(this.expression,"expected expression"),t.push({name:u.name,value:a});else{if(E(/^\.{3}/)){t.push({name:u.name,variadic:!0}),c=!0;break}t.push({name:u.name})}else t.push({value:u})}while(E(","));E(")")||(l=o,y()),E(/^when/)&&(f=x(this.conditions,"expected condition")),r=E(this.block);if(r)return new i.mixin.Definition(e,t,r,f,c);y()}}},entity:function(){return E(this.entities.literal)||E(this.entities.variable)||E(this.entities.url)||E(this.entities.call)||E(this.entities.keyword)||E(this.entities.javascript)||E(this.comment)},end:function(){return E(";")||N("}")},alpha:function(){var e;if(!E(/^\(opacity=/i))return;if(e=E(/^\d+/)||E(this.entities.variable))return x(")"),new i.Alpha(e)},element:function(){var e,t,n,r;n=E(this.combinator),e=E(/^(?:\d+\.\d+|\d+)%/)||E(/^(?:[.#]?|:*)(?:[\w-]|[^\x00-\x9f]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/)||E("*")||E("&")||E(this.attribute)||E(/^\([^)@]+\)/)||E(/^[\.#](?=@)/)||E(this.entities.variableCurly),e||E("(")&&(r=E(this.entities.variableCurly)||E(this.entities.variable))&&E(")")&&(e=new i.Paren(r));if(e)return new i.Element(n,e,o)},combinator:function(){var e,t=s.charAt(o);if(t===">"||t==="+"||t==="~"){o++;while(s.charAt(o).match(/\s/))o++;return new i.Combinator(t)}return s.charAt(o-1).match(/\s/)?new i.Combinator(" "):new i.Combinator(null)},selector:function(){var e,t,n=[],r,u;if(E("("))return e=E(this.entity),x(")"),new i.Selector([new i.Element("",e,o)]);while(t=E(this.element)){r=s.charAt(o),n.push(t);if(r==="{"||r==="}"||r===";"||r===",")break}if(n.length>0)return new i.Selector(n)},tag:function(){return E(/^[A-Za-z][A-Za-z-]*[0-9]?/)||E("*")},attribute:function(){var e="",t,n,r;if(!E("["))return;if(t=E(/^(?:[_A-Za-z0-9-]|\\.)+/)||E(this.entities.quoted))(r=E(/^[|~*$^]?=/))&&(n=E(this.entities.quoted)||E(/^[\w-]+/))?e=[t,r,n.toCSS?n.toCSS():n].join(""):e=t;if(!E("]"))return;if(e)return"["+e+"]"},block:function(){var e;if(E("{")&&(e=E(this.primary))&&E("}"))return e},ruleset:function(){var e=[],n,r,u,a;g(),t.dumpLineNumbers&&(a=A(o,s,t));while(n=E(this.selector)){e.push(n),E(this.comment);if(!E(","))break;E(this.comment)}if(e.length>0&&(r=E(this.block))){var f=new i.Ruleset(e,r,t.strictImports);return t.dumpLineNumbers&&(f.debugInfo=a),f}l=o,y()},rule:function(){var e,t,n=s.charAt(o),r,a;g();if(n==="."||n==="#"||n==="&")return;if(e=E(this.variable)||E(this.property)){e.charAt(0)!="@"&&(a=/^([^@+\/'"*`(;{}-]*);/.exec(c[u]))?(o+=a[0].length-1,t=new i.Anonymous(a[1])):e==="font"?t=E(this.font):t=E(this.value),r=E(this.important);if(t&&E(this.end))return new i.Rule(e,t,r,f);l=o,y()}},"import":function(){var e,t,n=o;g();var r=E(/^@import(?:-(once))?\s+/);if(r&&(e=E(this.entities.quoted)||E(this.entities.url))){t=E(this.mediaFeatures);if(E(";"))return new i.Import(e,m,t,r[1]==="once",n)}y()},mediaFeature:function(){var e,t,n=[];do if(e=E(this.entities.keyword))n.push(e);else if(E("(")){t=E(this.property),e=E(this.entity);if(!E(")"))return null;if(t&&e)n.push(new i.Paren(new i.Rule(t,e,null,o,!0)));else{if(!e)return null;n.push(new i.Paren(e))}}while(e);if(n.length>0)return new i.Expression(n)},mediaFeatures:function(){var e,t=[];do if(e=E(this.mediaFeature)){t.push(e);if(!E(","))break}else if(e=E(this.entities.variable)){t.push(e);if(!E(","))break}while(e);return t.length>0?t:null},media:function(){var e,n,r,u;t.dumpLineNumbers&&(u=A(o,s,t));if(E(/^@media/)){e=E(this.mediaFeatures);if(n=E(this.block))return r=new i.Media(n,e),t.dumpLineNumbers&&(r.debugInfo=u),r}},directive:function(){var e,t,n,r,u,a,f,l,c;if(s.charAt(o)!=="@")return;if(t=E(this["import"])||E(this.media))return t;g(),e=E(/^@[a-z-]+/),f=e,e.charAt(1)=="-"&&e.indexOf("-",2)>0&&(f="@"+e.slice(e.indexOf("-",2)+1));switch(f){case"@font-face":l=!0;break;case"@viewport":case"@top-left":case"@top-left-corner":case"@top-center":case"@top-right":case"@top-right-corner":case"@bottom-left":case"@bottom-left-corner":case"@bottom-center":case"@bottom-right":case"@bottom-right-corner":case"@left-top":case"@left-middle":case"@left-bottom":case"@right-top":case"@right-middle":case"@right-bottom":l=!0;break;case"@page":case"@document":case"@supports":case"@keyframes":l=!0,c=!0}c&&(e+=" "+(E(/^[^{]+/)||"").trim());if(l){if(n=E(this.block))return new i.Directive(e,n)}else if((t=E(this.entity))&&E(";"))return new i.Directive(e,t);y()},font:function(){var e=[],t=[],n,r,s,o;while(o=E(this.shorthand)||E(this.entity))t.push(o);e.push(new i.Expression(t));if(E(","))while(o=E(this.expression)){e.push(o);if(!E(","))break}return new i.Value(e)},value:function(){var e,t=[],n;while(e=E(this.expression)){t.push(e);if(!E(","))break}if(t.length>0)return new i.Value(t)},important:function(){if(s.charAt(o)==="!")return E(/^! *important/)},sub:function(){var e;if(E("(")&&(e=E(this.expression))&&E(")"))return e},multiplication:function(){var e,t,n,r;if(e=E(this.operand)){while(!N(/^\/\*/)&&(n=E("/")||E("*"))&&(t=E(this.operand)))r=new i.Operation(n,[r||e,t]);return r||e}},addition:function(){var e,t,n,r;if(e=E(this.multiplication)){while((n=E(/^[-+]\s+/)||!w(s.charAt(o-1))&&(E("+")||E("-")))&&(t=E(this.multiplication)))r=new i.Operation(n,[r||e,t]);return r||e}},conditions:function(){var e,t,n=o,r;if(e=E(this.condition)){while(E(",")&&(t=E(this.condition)))r=new i.Condition("or",r||e,t,n);return r||e}},condition:function(){var e,t,n,r,s=o,u=!1;E(/^not/)&&(u=!0),x("(");if(e=E(this.addition)||E(this.entities.keyword)||E(this.entities.quoted))return(r=E(/^(?:>=|=<|[<=>])/))?(t=E(this.addition)||E(this.entities.keyword)||E(this.entities.quoted))?n=new i.Condition(r,e,t,s,u):T("expected expression"):n=new i.Condition("=",e,new i.Keyword("true"),s,u),x(")"),E(/^and/)?new i.Condition("and",n,E(this.condition)):n},operand:function(){var e,t=s.charAt(o+1);s.charAt(o)==="-"&&(t==="@"||t==="(")&&(e=E("-"));var n=E(this.sub)||E(this.entities.dimension)||E(this.entities.color)||E(this.entities.variable)||E(this.entities.call);return e?new i.Operation("*",[new i.Dimension(-1),n]):n},expression:function(){var e,t,n=[],r;while(e=E(this.addition)||E(this.entity))n.push(e);if(n.length>0)return new i.Expression(n)},property:function(){var e;if(e=E(/^(\*?-?[_a-z0-9-]+)\s*:/))return e[1]}}}};if(r.mode==="browser"||r.mode==="rhino")r.Parser.importer=function(e,t,n,r){!/^([a-z-]+:)?\//.test(e)&&t.length>0&&(e=t[0]+e),d({href:e,title:e,type:r.mime,contents:r.contents},function(i){i&&typeof r.errback=="function"?r.errback.call(null,e,t,n,r):n.apply(null,arguments)},!0)};(function(e){function t(t){return e.functions.hsla(t.h,t.s,t.l,t.a)}function n(t){if(t instanceof e.Dimension)return parseFloat(t.unit=="%"?t.value/100:t.value);if(typeof t=="number")return t;throw{error:"RuntimeError",message:"color functions take numbers as parameters"}}function r(e){return Math.min(1,Math.max(0,e))}e.functions={rgb:function(e,t,n){return this.rgba(e,t,n,1)},rgba:function(t,r,i,s){var o=[t,r,i].map(function(e){return n(e)}),s=n(s);return new e.Color(o,s)},hsl:function(e,t,n){return this.hsla(e,t,n,1)},hsla:function(e,t,r,i){function u(e){return e=e<0?e+1:e>1?e-1:e,e*6<1?o+(s-o)*e*6:e*2<1?s:e*3<2?o+(s-o)*(2/3-e)*6:o}e=n(e)%360/360,t=n(t),r=n(r),i=n(i);var s=r<=.5?r*(t+1):r+t-r*t,o=r*2-s;return this.rgba(u(e+1/3)*255,u(e)*255,u(e-1/3)*255,i)},hue:function(t){return new e.Dimension(Math.round(t.toHSL().h))},saturation:function(t){return new e.Dimension(Math.round(t.toHSL().s*100),"%")},lightness:function(t){return new e.Dimension(Math.round(t.toHSL().l*100),"%")},red:function(t){return new e.Dimension(t.rgb[0])},green:function(t){return new e.Dimension(t.rgb[1])},blue:function(t){return new e.Dimension(t.rgb[2])},alpha:function(t){return new e.Dimension(t.toHSL().a)},luma:function(t){return new e.Dimension(Math.round((.2126*(t.rgb[0]/255)+.7152*(t.rgb[1]/255)+.0722*(t.rgb[2]/255))*t.alpha*100),"%")},saturate:function(e,n){var i=e.toHSL();return i.s+=n.value/100,i.s=r(i.s),t(i)},desaturate:function(e,n){var i=e.toHSL();return i.s-=n.value/100,i.s=r(i.s),t(i)},lighten:function(e,n){var i=e.toHSL();return i.l+=n.value/100,i.l=r(i.l),t(i)},darken:function(e,n){var i=e.toHSL();return i.l-=n.value/100,i.l=r(i.l),t(i)},fadein:function(e,n){var i=e.toHSL();return i.a+=n.value/100,i.a=r(i.a),t(i)},fadeout:function(e,n){var i=e.toHSL();return i.a-=n.value/100,i.a=r(i.a),t(i)},fade:function(e,n){var i=e.toHSL();return i.a=n.value/100,i.a=r(i.a),t(i)},spin:function(e,n){var r=e.toHSL(),i=(r.h+n.value)%360;return r.h=i<0?360+i:i,t(r)},mix:function(t,n,r){r||(r=new e.Dimension(50));var i=r.value/100,s=i*2-1,o=t.toHSL().a-n.toHSL().a,u=((s*o==-1?s:(s+o)/(1+s*o))+1)/2,a=1-u,f=[t.rgb[0]*u+n.rgb[0]*a,t.rgb[1]*u+n.rgb[1]*a,t.rgb[2]*u+n.rgb[2]*a],l=t.alpha*i+n.alpha*(1-i);return new e.Color(f,l)},greyscale:function(t){return this.desaturate(t,new e.Dimension(100))},contrast:function(e,t,n,r){return typeof n=="undefined"&&(n=this.rgba(255,255,255,1)),typeof t=="undefined"&&(t=this.rgba(0,0,0,1)),typeof r=="undefined"?r=.43:r=r.value,(.2126*(e.rgb[0]/255)+.7152*(e.rgb[1]/255)+.0722*(e.rgb[2]/255))*e.alpha255?255:e<0?0:e).toString(16),e.length===1?"0"+e:e}).join("")},operate:function(t,n){var r=[];n instanceof e.Color||(n=n.toColor());for(var i=0;i<3;i++)r[i]=e.operate(t,this.rgb[i],n.rgb[i]);return new e.Color(r,this.alpha+n.alpha)},toHSL:function(){var e=this.rgb[0]/255,t=this.rgb[1]/255,n=this.rgb[2]/255,r=this.alpha,i=Math.max(e,t,n),s=Math.min(e,t, 9 | n),o,u,a=(i+s)/2,f=i-s;if(i===s)o=u=0;else{u=a>.5?f/(2-i-s):f/(i+s);switch(i){case e:o=(t-n)/f+(t255?255:e<0?0:e).toString(16),e.length===1?"0"+e:e}).join("")},compare:function(e){return e.rgb?e.rgb[0]===this.rgb[0]&&e.rgb[1]===this.rgb[1]&&e.rgb[2]===this.rgb[2]&&e.alpha===this.alpha?0:-1:-1}}}(n("../tree")),function(e){e.Comment=function(e,t){this.value=e,this.silent=!!t},e.Comment.prototype={toCSS:function(e){return e.compress?"":this.value},eval:function(){return this}}}(n("../tree")),function(e){e.Condition=function(e,t,n,r,i){this.op=e.trim(),this.lvalue=t,this.rvalue=n,this.index=r,this.negate=i},e.Condition.prototype.eval=function(e){var t=this.lvalue.eval(e),n=this.rvalue.eval(e),r=this.index,i,i=function(e){switch(e){case"and":return t&&n;case"or":return t||n;default:if(t.compare)i=t.compare(n);else{if(!n.compare)throw{type:"Type",message:"Unable to perform comparison",index:r};i=n.compare(t)}switch(i){case-1:return e==="<"||e==="=<";case 0:return e==="="||e===">="||e==="=<";case 1:return e===">"||e===">="}}}(this.op);return this.negate?!i:i}}(n("../tree")),function(e){e.Dimension=function(e,t){this.value=parseFloat(e),this.unit=t||null},e.Dimension.prototype={eval:function(){return this},toColor:function(){return new e.Color([this.value,this.value,this.value])},toCSS:function(){var e=this.value+this.unit;return e},operate:function(t,n){return new e.Dimension(e.operate(t,this.value,n.value),this.unit||n.unit)},compare:function(t){return t instanceof e.Dimension?t.value>this.value?-1:t.value":e.compress?">":" > "}[this.value]}}(n("../tree")),function(e){e.Expression=function(e){this.value=e},e.Expression.prototype={eval:function(t){return this.value.length>1?new e.Expression(this.value.map(function(e){return e.eval(t)})):this.value.length===1?this.value[0].eval(t):this},toCSS:function(e){return this.value.map(function(t){return t.toCSS?t.toCSS(e):""}).join(" ")}}}(n("../tree")),function(e){e.Import=function(t,n,r,i,s){var o=this;this.once=i,this.index=s,this._path=t,this.features=r&&new e.Value(r),t instanceof e.Quoted?this.path=/\.(le?|c)ss(\?.*)?$/.test(t.value)?t.value:t.value+".less":this.path=t.value.value||t.value,this.css=/css(\?.*)?$/.test(this.path),this.css||n.push(this.path,function(t,n,r){t&&(t.index=s),r&&o.once&&(o.skip=r),o.root=n||new e.Ruleset([],[])})},e.Import.prototype={toCSS:function(e){var t=this.features?" "+this.features.toCSS(e):"";return this.css?"@import "+this._path.toCSS()+t+";\n":""},eval:function(t){var n,r=this.features&&this.features.eval(t);if(this.skip)return[];if(this.css)return this;n=new e.Ruleset([],this.root.rules.slice(0));for(var i=0;i1){var r=this.emptySelectors();n=new e.Ruleset(r,t.mediaBlocks),n.multiMedia=!0}return delete t.mediaBlocks,delete t.mediaPath,n},evalNested:function(t){var n,r,i=t.mediaPath.concat([this]);for(n=0;n0;n--)t.splice(n,0,new e.Anonymous("and"));return new e.Expression(t)})),new e.Ruleset([],[])},permute:function(e){if(e.length===0)return[];if(e.length===1)return e[0];var t=[],n=this.permute(e.slice(1));for(var r=0;r0){n=this.arguments&&this.arguments.map(function(t){return{name:t.name,value:t.value.eval(e)}});for(var o=0;othis.params.length)return!1;if(this.required>0&&n>this.params.length)return!1}if(this.condition&&!this.condition.eval({frames:[this.evalParams(t,e)].concat(t.frames)}))return!1;r=Math.min(n,this.arity);for(var s=0;si.selectors[o].elements.length?Array.prototype.push.apply(r,i.find(new e.Selector(t.elements.slice(1)),n)):r.push(i);break}}),this._lookups[o]=r)},toCSS:function(t,n){var r=[],i=[],s=[],o=[],u=[],a,f,l;this.root||this.joinSelectors(u,t,this.selectors);for(var c=0;c0){f=e.debugInfo(n,this),a=u.map(function(e){return e.map(function(e){return e.toCSS(n)}).join("").trim()}).join(n.compress?",":",\n");for(var c=i.length-1;c>=0;c--)s.indexOf(i[c])===-1&&s.unshift(i[c]);i=s,r.push(f+a+(n.compress?"{":" {\n ")+i.join(n.compress?"":"\n ")+(n.compress?"}":"\n}\n"))}return r.push(o),r.join("")+(n.compress?"\n":"")},joinSelectors:function(e,t,n){for(var r=0;r0)for(i=0;i0&&this.mergeElementsOnToSelectors(g,a);for(s=0;s0&&(l[0].elements=l[0].elements.slice(0),l[0].elements.push(new e.Element(f.combinator,"",0))),y.push(l);else for(o=0;o0?(h=l.slice(0),m=h.pop(),d=new e.Selector(m.elements.slice(0)),v=!1):d=new e.Selector([]),c.length>1&&(p=p.concat(c.slice(1))),c.length>0&&(v=!1,d.elements.push(new e.Element(f.combinator,c[0].elements[0].value,0)),d.elements=d.elements.concat(c[0].elements.slice(1))),v||h.push(d),h=h.concat(p),y.push(h)}a=y,g=[]}}g.length>0&&this.mergeElementsOnToSelectors(g,a);for(i=0;i0?i[i.length-1]=new e.Selector(i[i.length-1].elements.concat(t)):i.push(new e.Selector(t))}}}(n("../tree")),function(e){e.Selector=function(e){this.elements=e},e.Selector.prototype.match=function(e){var t=this.elements.length,n=e.elements.length,r=Math.min(t,n);if(t0&&(r.value=this.paths[0]+(r.value.charAt(0)==="/"?r.value.slice(1):r.value)),new t.URL(r,this.paths)}}}(n("../tree")),function(e){e.Value=function(e){this.value=e,this.is="value"},e.Value.prototype={eval:function(t){return this.value.length===1?this.value[0].eval(t):new e.Value(this.value.map(function(e){return e.eval(t)}))},toCSS:function(e){return this.value.map(function(t){return t.toCSS(e)}).join(e.compress?",":", ")}}}(n("../tree")),function(e){e.Variable=function(e,t,n){this.name=e,this.index=t,this.file=n},e.Variable.prototype={eval:function(t){var n,r,i=this.name;i.indexOf("@@")==0&&(i="@"+(new e.Variable(i.slice(1))).eval(t).value);if(n=e.find(t.frames,function(e){if(r=e.variable(i))return r.value.eval(t)}))return n;throw{type:"Name",message:"variable "+i+" is undefined",filename:this.file,index:this.index}}}}(n("../tree")),function(e){e.debugInfo=function(t,n){var r="";if(t.dumpLineNumbers&&!t.compress)switch(t.dumpLineNumbers){case"comments":r=e.debugInfo.asComment(n);break;case"mediaquery":r=e.debugInfo.asMediaQuery(n);break;case"all":r=e.debugInfo.asComment(n)+e.debugInfo.asMediaQuery(n)}return r},e.debugInfo.asComment=function(e){return"/* line "+e.debugInfo.lineNumber+", "+e.debugInfo.fileName+" */\n"},e.debugInfo.asMediaQuery=function(e){return'@media -sass-debug-info{filename{font-family:"'+e.debugInfo.fileName+'";}line{font-family:"'+e.debugInfo.lineNumber+'";}}\n'},e.find=function(e,t){for(var n=0,r;n1?"["+e.value.map(function(e){return e.toCSS(!1)}).join(", ")+"]":e.toCSS(!1)}}(n("./tree"));var s=/^(file|chrome(-extension)?|resource|qrc|app):/.test(location.protocol);r.env=r.env||(location.hostname=="127.0.0.1"||location.hostname=="0.0.0.0"||location.hostname=="localhost"||location.port.length>0||s?"development":"production"),r.async=r.async||!1,r.fileAsync=r.fileAsync||!1,r.poll=r.poll||(s?1e3:1500),r.watch=function(){return this.watchMode=!0},r.unwatch=function(){return this.watchMode=!1};if(r.env==="development"){r.optimization=0,/!watch/.test(location.hash)&&r.watch();var o=/!dumpLineNumbers:(comments|mediaquery|all)/.exec(location.hash);o&&(r.dumpLineNumbers=o[1]),r.watchTimer=setInterval(function(){r.watchMode&&p(function(e,t,n,r,i){t&&m(t.toCSS(),r,i.lastModified)})},r.poll)}else r.optimization=3;var u;try{u=typeof e.localStorage=="undefined"?null:e.localStorage}catch(a){u=null}var f=document.getElementsByTagName("link"),l=/^text\/(x-)?less$/;r.sheets=[];for(var c=0;c