├── .gitattributes ├── .gitignore ├── COPYING ├── LICENSE ├── README.md ├── cache └── .gitignore ├── docs ├── Makefile ├── _static │ └── .gitignore ├── _templates │ └── .gitignore ├── conf.py ├── data.rst ├── deployment.rst ├── development.rst ├── images │ ├── data.png │ ├── step1.png │ ├── step2.png │ ├── step3.png │ └── step4.png ├── index.rst └── screenshots.rst ├── manage.py ├── plan ├── __init__.py ├── common │ ├── __init__.py │ ├── context_processors.py │ ├── encoding.py │ ├── fixtures │ │ ├── test_data.json │ │ └── test_user.json │ ├── forms.py │ ├── logger.py │ ├── managers.py │ ├── middleware.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_location.py │ │ ├── 0003_course_locations.py │ │ ├── 0004_set_default_location.py │ │ ├── 0005_lecture_title.py │ │ ├── 0006_auto_20210314_1651.py │ │ ├── 0007_update_url_fields.py │ │ ├── 0008_update_semester_type.py │ │ ├── 0009_update_id_types.py │ │ ├── 0010_update_url_type_to_text_field.py │ │ ├── 0011_add_summary_and_stream.py │ │ ├── 0012_add_last_modified.py │ │ └── __init__.py │ ├── models.py │ ├── sprite.py │ ├── templatetags │ │ ├── __init__.py │ │ ├── color.py │ │ ├── compact.py │ │ ├── get.py │ │ ├── hostname.py │ │ ├── nbsp.py │ │ ├── nonce.py │ │ ├── slugify.py │ │ ├── strip.py │ │ ├── tabindex.py │ │ └── title.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_managers.py │ │ ├── test_models.py │ │ ├── test_timetable.py │ │ ├── test_utils.py │ │ └── test_views.py │ ├── timetable.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── ical │ ├── __init__.py │ ├── models.py │ ├── tests.py │ ├── urls.py │ └── views.py ├── locales │ └── nb │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── pdf │ ├── __init__.py │ ├── models.py │ ├── tests.py │ ├── urls.py │ └── views.py ├── scrape │ ├── __init__.py │ ├── base.py │ ├── fetch.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── prefetch.py │ │ │ └── scrape.py │ ├── ntnu │ │ ├── __init__.py │ │ ├── akademika.py │ │ ├── maze.py │ │ ├── tp.py │ │ ├── web.py │ │ └── web.sample │ ├── tests.py │ └── utils.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── external.py │ ├── local.py-template │ └── test.py ├── static │ ├── css │ │ ├── base.css │ │ ├── fonts.css │ │ ├── grids.css │ │ ├── icons.css │ │ ├── reset.css │ │ ├── schedule.css │ │ ├── select_groups.css │ │ ├── start.css │ │ └── style.css │ ├── gfx │ │ └── icons │ │ │ ├── arrow-right.png │ │ │ ├── ban-circle.png │ │ │ ├── book.png │ │ │ ├── bookmark.png │ │ │ ├── briefcase.png │ │ │ ├── calendar.png │ │ │ ├── cog.png │ │ │ ├── cogs.png │ │ │ ├── download-alt.png │ │ │ ├── download.png │ │ │ ├── external-link.png │ │ │ ├── facebook-sign.png │ │ │ ├── flag.png │ │ │ ├── flattr-sign.png │ │ │ ├── globe.png │ │ │ ├── google-plus-sign.png │ │ │ ├── info-sign.png │ │ │ ├── link.png │ │ │ ├── list-alt.png │ │ │ ├── list.png │ │ │ ├── pencil.png │ │ │ ├── plus-sign.png │ │ │ ├── question-sign.png │ │ │ ├── remove.png │ │ │ ├── save.png │ │ │ ├── sprite.png │ │ │ ├── time.png │ │ │ ├── twitter-sign.png │ │ │ ├── warning-sign.png │ │ │ └── wrench.png │ ├── js │ │ ├── advanced.js │ │ ├── autocomplete.js │ │ ├── calendar.js │ │ ├── lib │ │ │ ├── auto-complete.css │ │ │ ├── auto-complete.min.js │ │ │ ├── d3.v7.js │ │ │ ├── excanvas.min.js │ │ │ ├── htl.v0.3.1.js │ │ │ ├── jquery-1.7.2.min.js │ │ │ ├── jquery.flot.min.js │ │ │ └── plot.v0.6.js │ │ ├── navigation.js │ │ └── toggle.js │ ├── map │ │ ├── auditorier_dragvoll.png │ │ └── auditorier_gloshaugen.png │ └── screenshot.png ├── templates │ ├── 404.html │ ├── 500.html │ ├── about.html │ ├── add_courses.html │ ├── base.html │ ├── base_site.html │ ├── compressor │ │ └── js_file.html │ ├── courses.html │ ├── error.html │ ├── groups.html │ ├── groups_link.html │ ├── lectures.html │ ├── notice.html │ ├── schedule.html │ ├── schedule_message.html │ ├── schedule_table.html │ ├── schedule_table_footer.html │ ├── select_groups.html │ ├── setlang.html │ ├── start.html │ ├── statistics.html │ ├── tips.html │ └── title.html ├── urls.py └── wsgi.py ├── pyproject.toml ├── static └── .gitignore └── uv.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | uv.lock linguist-generated=true merge=binary 2 | plan/static/lib/* linguist-generated=true merge=binary 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /plan/settings/local.py 2 | /settings.py 3 | /TODO 4 | 5 | *.bak 6 | *.orig 7 | *.pyc 8 | *.sqlite 9 | __pycache__ 10 | .direnv 11 | .envrc 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | plan 2 | ---- 3 | 4 | Copyright 2008-2012 Thomas Kongevold Adamcik 5 | 6 | License: AGPLv3+ 7 | 8 | Plan is free software: you can redistribute it and/or modify 9 | it under the terms of the Affero GNU General Public License as 10 | published by the Free Software Foundation, either version 3 of 11 | the License, or (at your option) any later version. 12 | 13 | Plan is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | Affero GNU General Public License for more details. 17 | 18 | You should have received a copy of the Affero GNU General Public 19 | License along with Plan. If not, see . 20 | 21 | See COPYING for full license. 22 | 23 | 24 | The following license information covers the external libraries directly 25 | included in this distrubtion: 26 | 27 | 28 | ColorBrewer 29 | ----------- 30 | 31 | Copyright (c) 2002 Cynthia Brewer, Mark Harrower, and The Pennsylvania State University. 32 | 33 | http://colorbrewer2.org/ 34 | 35 | License: Apache 2.0 36 | 37 | Licensed under the Apache License, Version 2.0 (the "License"); you may not 38 | use this file except in compliance with the License. You may obtain a copy of 39 | the License at 40 | 41 | http://www.apache.org/licenses/LICENSE-2.0 42 | 43 | Unless required by applicable law or agreed to in writing, software distributed 44 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 45 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 46 | specific language governing permissions and limitations under the License. 47 | 48 | 49 | jQuery 50 | ------ 51 | 52 | Copyright (c) 2009 John Resig 53 | 54 | http://www.jquery.com/ 55 | 56 | License: MIT/GPL 57 | 58 | 59 | jQuery Autocomplete Plugin 60 | -------------------------- 61 | 62 | Copyright (c) 2007 Dylan Verheul, Dan G. Switzer, Anjesh Tuladhar, Jörn Zaefferer 63 | 64 | http://bassistance.de/jquery-plugins/jquery-plugin-autocomplete/ 65 | 66 | License: MIT/GPL 67 | 68 | 69 | flot 70 | ---- 71 | 72 | Copyright (c) 2007-2009 IOLA and Ole Laursen 73 | 74 | http://code.google.com/p/flot 75 | 76 | License: MIT 77 | 78 | 79 | ExplorerCanvas 80 | -------------- 81 | 82 | Copyright 2006 Google Inc. 83 | 84 | http://code.google.com/p/explorercanvas 85 | 86 | License: Apache 2.0 87 | 88 | 89 | Font Awesome 90 | ------------ 91 | 92 | http://fortawesome.github.com/Font-Awesome 93 | 94 | License: Creative Commons Attribution 3.0 95 | 96 | 97 | YUI CSS-framwork 98 | ---------------- 99 | 100 | Copyright (c) 2009, Yahoo! Inc. All rights reserved. 101 | 102 | http://developer.yahoo.com/yui/grids/ 103 | 104 | License: BSD 105 | 106 | Redistribution and use in source and binary forms, with or without modification, 107 | are permitted provided that the following conditions are met: 108 | 109 | 1. Redistributions of source code must retain the above copyright notice, 110 | this list of conditions and the following disclaimer. 111 | 112 | 2. Redistributions in binary form must reproduce the above copyright 113 | notice, this list of conditions and the following disclaimer in the 114 | documentation and/or other materials provided with the distribution. 115 | 116 | 3. Neither the name of Yahoo! Inc. nor the names of its contributors 117 | may be used to endorse or promote products derived from this 118 | software without specific prior written permission of Yahoo! Inc. 119 | 120 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 121 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 122 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 123 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 124 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 125 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 126 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 127 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 128 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 129 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plan, a timetable generator for NTNU students 2 | 3 | This piece of software started out as a simple tool to assist in creating 4 | readable timetables for NTNU courses. The earliest version of the site provided 5 | this functionality and nothing more. As more fellow students started using the 6 | software new features where added based on personal needs and suggestions from 7 | other students. 8 | 9 | ## Today the software provides the following: 10 | 11 | - Simple interface for adding courses. 12 | - Customizable view of your timetable. 13 | - Easy export to Google-Calendar via iCal. 14 | - PDF-version for printing. 15 | - User defined deadlines. 16 | - Import of course data from ntnu.no or database-dumps. 17 | 18 | ## Required packages 19 | 20 | - python3-django 21 | - python3-django-compressor 22 | - python3-lxml 23 | - python3-psycopg2 24 | - python3-pylibmc 25 | - python3-reportlab 26 | - python3-requests 27 | - python3-sentry-sdk 28 | - python3-sphinx 29 | - python3-tqdm 30 | - python3-vobject 31 | -------------------------------------------------------------------------------- /cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " pickle to make pickle files" 22 | @echo " json to make JSON files" 23 | @echo " htmlhelp to make HTML files and a HTML help project" 24 | @echo " qthelp to make HTML files and a qthelp project" 25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 26 | @echo " changes to make an overview of all changed/added/deprecated items" 27 | @echo " linkcheck to check all external links for integrity" 28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 29 | 30 | clean: 31 | -rm -rf $(BUILDDIR)/* 32 | 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | dirhtml: 39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 40 | @echo 41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 42 | 43 | pickle: 44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 45 | @echo 46 | @echo "Build finished; now you can process the pickle files." 47 | 48 | json: 49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 50 | @echo 51 | @echo "Build finished; now you can process the JSON files." 52 | 53 | htmlhelp: 54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 55 | @echo 56 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 57 | ".hhp project file in $(BUILDDIR)/htmlhelp." 58 | 59 | qthelp: 60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 61 | @echo 62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/plan.qhcp" 65 | @echo "To view the help file:" 66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/plan.qhc" 67 | 68 | latex: 69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 70 | @echo 71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 73 | "run these through (pdf)latex." 74 | 75 | changes: 76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 77 | @echo 78 | @echo "The overview file is in $(BUILDDIR)/changes." 79 | 80 | linkcheck: 81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 82 | @echo 83 | @echo "Link check complete; look for any errors in the above output " \ 84 | "or in $(BUILDDIR)/linkcheck/output.txt." 85 | 86 | doctest: 87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 88 | @echo "Testing of doctests in the sources finished, look at the " \ 89 | "results in $(BUILDDIR)/doctest/output.txt." 90 | -------------------------------------------------------------------------------- /docs/_static/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/docs/_static/.gitignore -------------------------------------------------------------------------------- /docs/_templates/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/docs/_templates/.gitignore -------------------------------------------------------------------------------- /docs/data.rst: -------------------------------------------------------------------------------- 1 | Data administration 2 | =================== 3 | 4 | All of the websites data-models can be edited through the administration 5 | site `/timeplan/admin`. However in most cases the admin pages won't be 6 | used at all. 7 | 8 | Data manipulation is mainly done through the ``./manage.py`` command. 9 | This script gives access to a host of Django management commands (see 10 | ``./manage.py help``. 11 | 12 | Creating new super-users 13 | ------------------------ 14 | 15 | :: 16 | 17 | > /manage.py createsuperuser 18 | Username (Leave blank to use 'adamcik'): 19 | E-mail address: adamcik@stud.ntnu.no 20 | Password: 21 | Password (again): 22 | Superuser created successfully. 23 | 24 | Loading courses 25 | --------------- 26 | 27 | :: 28 | 29 | > ./manage.py courses -w 30 | [2009-08-11 15:19:04 INFO] Updating courses for fall 2009 31 | [2009-08-11 15:19:04 INFO] Retrieving 32 | http://www.ntnu.no/studieinformasjon/timeplan/h09/?bokst=A 33 | [2009-08-11 15:19:05 INFO] Retrieving 34 | http://www.ntnu.no/studieinformasjon/timeplan/h09/?bokst=B 35 | ... 36 | [2009-08-11 15:19:32 INFO] Saved course ZO2051 37 | [2009-08-11 15:19:32 INFO] Saved course ZO3020 38 | Save changes? [y/N] y 39 | Saving changes... 40 | 41 | -w tells the script to scrape the NTNU web page, if a the MySQL database with 42 | NTNU courses is available and setup omitting -w means it will be used instead. 43 | All management commands take the option --help. 44 | 45 | Loading lectures 46 | ---------------- 47 | 48 | :: 49 | 50 | > ./manage.py lectures -w 51 | [2009-08-11 15:20:24 INFO] Updating lectures for fall 2009 52 | [2009-08-11 15:20:24 INFO] Retrieving 53 | http://www.ntnu.no/studieinformasjon/timeplan/h09/?emnekode=AAR1050-1 54 | [2009-08-11 15:20:24 INFO] Retrieving 55 | http://www.ntnu.no/studieinformasjon/timeplan/h09/?emnekode=AAR4205-1 56 | ... 57 | [2009-08-11 15:21:10 INFO] Saved 87 ZO2051-1 - fall 2009 14:15-17:00 on Fri 58 | [2009-08-11 15:21:10 INFO] Saved 88 ZO3020-1 - fall 2009 09:15-12:00 on Mon 59 | Save changes? [y/N] y 60 | Saving changes... 61 | 62 | 63 | Running lectures -w, ie. web-retrieval normally takes 5-6 min, to save 64 | time use the -m option to limit which courses to import. 65 | 66 | Loading exams 67 | ------------- 68 | 69 | :: 70 | 71 | > ./manage.py exams 72 | [2009-08-13 14:15:28 INFO] Updating exams for fall 2009 73 | [2009-08-13 14:15:28 INFO] Getting url: 74 | http://www.ntnu.no/eksamen/plan/09h/dato.XML 75 | [2009-08-13 14:15:35 WARNING] ENG2153's exam does not have a date. 76 | [2009-08-13 14:15:40 WARNING] MV1000's exam is in the past - 2009-05-08 77 | [2009-08-13 14:15:52 INFO] Added 1051 exams 78 | [2009-08-13 14:15:52 INFO] Updated 0 exams 79 | Save changes? [y/N] y 80 | Saving changes... 81 | 82 | Flushing the cache realms 83 | ------------------------- 84 | 85 | :: 86 | 87 | > ./manage.py flushrealms 88 | [2010-05-22 01:21:15 INFO] Flushing cache for fall 2009 89 | 90 | Clear cache for all users connected to a semester. 91 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | .. seealso:: 5 | `General Django documentation `_ 6 | 7 | Quickstart 8 | ---------- 9 | 10 | The following recipe should have you up and running with a local development 11 | instance of the site in no time. 12 | 13 | #. Retrieve source from VCS. 14 | #. ``> cd plan`` 15 | #. ``> ./manage.py syncdb`` 16 | #. ``> ./manage.py synccompress`` 17 | #. ``> ./manage.py courses -w`` 18 | #. ``> ./manage.py exams`` 19 | #. ``> ./manage.py lectures -w`` 20 | #. ``> ./manage.py runserver`` 21 | #. http://localhost:8000 22 | 23 | Datamodel 24 | --------- 25 | 26 | .. image:: images/data.png 27 | :target: ../_images/data.png 28 | :width: 300px 29 | 30 | Translations 31 | ------------ 32 | 33 | Note that :mod:`plan.translation` provides the i18n templatetags for this 34 | project. Ie. use ``{% load translation %}`` instead of ``{% load i18n %}`` 35 | in templates. 36 | 37 | This package provides the same tags as i18n, and the ``{% language %}`` tag 38 | in addition. 39 | 40 | .. seealso:: 41 | ``_ 42 | 43 | Running tests 44 | ------------- 45 | 46 | Plan has a decent level of test coverage that ensures that most of the building 47 | blocks and basic use-cases for the site remain functioning. 48 | 49 | :: 50 | 51 | > ./manage.py test 52 | Creating test database... 53 | Creating table django_admin_log 54 | Creating table auth_permission 55 | ... 56 | Installing index for common.Lecture model 57 | Installing index for common.Deadline model 58 | ........................................... 59 | ---------------------------------------------------------------------- 60 | Ran 43 tests in 26.874s 61 | 62 | OK 63 | Destroying test database... 64 | 65 | Django's test framework assumes that the database-user has CREATE DATABASE 66 | rights in order to create a test database that can be completely reset. If the 67 | user setup does not have these rights running the test with the following 68 | command will run the tests with an in-memory SQLite3 database: ``./manage.py 69 | test --settings=settings.test`` 70 | 71 | .. seealso:: 72 | ``_ 73 | -------------------------------------------------------------------------------- /docs/images/data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/docs/images/data.png -------------------------------------------------------------------------------- /docs/images/step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/docs/images/step1.png -------------------------------------------------------------------------------- /docs/images/step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/docs/images/step2.png -------------------------------------------------------------------------------- /docs/images/step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/docs/images/step3.png -------------------------------------------------------------------------------- /docs/images/step4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/docs/images/step4.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Plan 2 | ==== 3 | 4 | This piece of software started out as a simple tool to assist 5 | in creating readable timetables from lecture times at NTNU. 6 | The earliest version of the site provided this functionality 7 | and nothing more. As more fellow students started using the 8 | software new features where added based on personal needs 9 | and suggestions from other students. 10 | 11 | Today the software provides the following: 12 | 13 | * Simple interface for adding courses. 14 | * Customizable view of your timetable. 15 | * Easy export to Google-Calendar via iCal. 16 | * PDF-version for printing. 17 | * User defined deadlines. 18 | * Import of course data from ntnu.no or database-dumps. 19 | 20 | Contents 21 | -------- 22 | 23 | .. toctree:: 24 | :maxdepth: 2 25 | :glob: 26 | 27 | screenshots 28 | data 29 | deployment 30 | development 31 | -------------------------------------------------------------------------------- /docs/screenshots.rst: -------------------------------------------------------------------------------- 1 | Screenshots 2 | =========== 3 | 4 | Creating a timetable 5 | -------------------- 6 | .. image:: images/step1.png 7 | :target: ../_images/step1.png 8 | :width: 300px 9 | 10 | Adding courses 11 | -------------- 12 | .. image:: images/step2.png 13 | :target: ../_images/step2.png 14 | :width: 300px 15 | 16 | Selecting groups/parallel 17 | ------------------------- 18 | .. image:: images/step3.png 19 | :target: ../_images/step3.png 20 | :width: 300px 21 | 22 | Extra features 23 | -------------- 24 | .. image:: images/step4.png 25 | :target: ../_images/step4.png 26 | :width: 300px 27 | -------------------------------------------------------------------------------- /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", "plan.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /plan/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/__init__.py -------------------------------------------------------------------------------- /plan/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/common/__init__.py -------------------------------------------------------------------------------- /plan/common/context_processors.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import socket 4 | import urllib.parse 5 | 6 | from django import urls 7 | from django.conf import settings 8 | from django.utils import translation 9 | 10 | _ = translation.gettext_lazy 11 | 12 | 13 | def processor(request): 14 | sitename = settings.TIMETABLE_HOSTNAME or request.headers.get( 15 | "Host", socket.getfqdn() 16 | ) 17 | scheme = "https://" if request.is_secure() else "http://" 18 | url = scheme + sitename + urls.reverse("frontpage") 19 | 20 | share_links = [] 21 | for icon, name, link in settings.TIMETABLE_SHARE_LINKS: 22 | share_links.append((icon, name, link % {"url": url})) 23 | 24 | static_domain = urllib.parse.urlparse(settings.STATIC_URL).netloc.split(":")[0] 25 | if static_domain == sitename: 26 | static_domain = None 27 | 28 | return { 29 | "ANALYTICS_CODE": settings.TIMETABLE_ANALYTICS_CODE, 30 | "INSTITUTION": settings.TIMETABLE_INSTITUTION, 31 | "INSTITUTION_SITE": settings.TIMETABLE_INSTITUTION_SITE, 32 | "SHOW_SYLLABUS": settings.TIMETABLE_SHOW_SYLLABUS, 33 | "ADMINS": settings.ADMINS, 34 | "SHARE_LINKS": share_links, 35 | "SOURCE_URL": settings.TIMETABLE_SOURCE_URL, 36 | "STATIC_DOMAIN": static_domain, 37 | "SITENAME": sitename, 38 | "CSP_NONCE": getattr(request, "_csp_nonce", None), 39 | } 40 | -------------------------------------------------------------------------------- /plan/common/encoding.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | 4 | def zig_zag_encode(i: int): 5 | return (i >> 31) ^ (i << 1) 6 | 7 | 8 | def zig_zag_decode(i: int): 9 | return (i >> 1) ^ -(i & 1) 10 | 11 | 12 | class DeltaDeltaEncoder: 13 | def __init__(self): 14 | self.prev: int | None = None 15 | self.prev_delta: int = 0 16 | 17 | def encode(self, value: int) -> int: 18 | if self.prev is None: 19 | self.prev = value 20 | return value 21 | 22 | delta = value - self.prev 23 | delta_delta = delta - self.prev_delta 24 | 25 | self.prev = value 26 | self.prev_delta = delta 27 | 28 | return delta_delta 29 | 30 | 31 | class DeltaDeltaDecoder: 32 | def __init__(self): 33 | self.prev: int | None = None 34 | self.prev_delta: int = 0 35 | 36 | def decode(self, value: int) -> int: 37 | if self.prev is None: 38 | self.prev = value 39 | return value 40 | 41 | self.prev_delta += value 42 | self.prev += self.prev_delta 43 | return self.prev 44 | -------------------------------------------------------------------------------- /plan/common/fixtures/test_user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "common.student", 5 | "fields": { 6 | "slug": "adamcik" 7 | } 8 | }, 9 | { 10 | "pk": 2, 11 | "model": "common.student", 12 | "fields": { 13 | "slug": "foo" 14 | } 15 | }, 16 | { 17 | "pk": 3, 18 | "model": "common.student", 19 | "fields": { 20 | "slug": "baz" 21 | } 22 | }, 23 | { 24 | "pk": 4, 25 | "model": "common.student", 26 | "fields": { 27 | "slug": "bar" 28 | } 29 | }, 30 | { 31 | "pk": 1, 32 | "model": "common.subscription", 33 | "fields": { 34 | "added": "2009-01-03 10:13:53", 35 | "alias": "foo", 36 | "course": 1, 37 | "groups": [ 38 | 1, 39 | 2 40 | ], 41 | "exclude": [ 42 | 7 43 | ], 44 | "student": "1" 45 | } 46 | }, 47 | { 48 | "pk": 2, 49 | "model": "common.subscription", 50 | "fields": { 51 | "added": "2009-01-03 10:16:14", 52 | "alias": "", 53 | "course": 2, 54 | "groups": [ 55 | 1 56 | ], 57 | "exclude": [], 58 | "student": "1" 59 | } 60 | }, 61 | { 62 | "pk": 3, 63 | "model": "common.subscription", 64 | "fields": { 65 | "added": "2009-01-03 10:16:21", 66 | "alias": "", 67 | "course": 3, 68 | "groups": [ 69 | 2 70 | ], 71 | "exclude": [], 72 | "student": "1" 73 | } 74 | }, 75 | { 76 | "pk": 4, 77 | "model": "common.subscription", 78 | "fields": { 79 | "added": "2009-01-03 13:41:56", 80 | "alias": "", 81 | "course": 1, 82 | "groups": [ 83 | 1 84 | ], 85 | "exclude": [], 86 | "student": "2" 87 | } 88 | }, 89 | { 90 | "pk": 5, 91 | "model": "common.subscription", 92 | "fields": { 93 | "added": "2009-01-03 13:42:14", 94 | "alias": "", 95 | "course": 2, 96 | "groups": [ 97 | 2 98 | ], 99 | "exclude": [], 100 | "student": "2" 101 | } 102 | }, 103 | { 104 | "pk": 6, 105 | "model": "common.subscription", 106 | "fields": { 107 | "added": "2009-01-03 13:42:14", 108 | "alias": "", 109 | "course": 2, 110 | "groups": [ 111 | 2 112 | ], 113 | "exclude": [], 114 | "student": "3" 115 | } 116 | }, 117 | { 118 | "pk": 7, 119 | "model": "common.subscription", 120 | "fields": { 121 | "added": "2009-01-03 13:42:14", 122 | "alias": "", 123 | "course": 5, 124 | "groups": [ 125 | ], 126 | "exclude": [], 127 | "student": "4" 128 | } 129 | } 130 | ] 131 | -------------------------------------------------------------------------------- /plan/common/forms.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import datetime 4 | 5 | from django import forms 6 | 7 | from plan.common import utils 8 | from plan.common.templatetags import slugify 9 | 10 | now = datetime.datetime.now # To allow for overriding of now in test 11 | 12 | 13 | class CourseAliasForm(forms.Form): 14 | """Form for changing subscription names""" 15 | 16 | alias = forms.CharField(widget=forms.TextInput(attrs={"size": 8}), required=False) 17 | 18 | def clean_alias(self): 19 | alias = self.cleaned_data["alias"].strip() 20 | 21 | if len(alias) > 40: 22 | alias = "%s..." % alias[:40] 23 | 24 | return alias 25 | 26 | 27 | class GroupForm(forms.Form): 28 | """Form for selecting groups for a course""" 29 | 30 | groups = forms.MultipleChoiceField( 31 | required=False, widget=forms.CheckboxSelectMultiple 32 | ) 33 | 34 | def __init__(self, choices, *args, **kwargs): 35 | super().__init__(*args, **kwargs) 36 | 37 | self.fields["groups"].choices = utils.natural_sort(choices, key=lambda v: v[1]) 38 | self.fields["groups"].widget.attrs["size"] = 5 39 | 40 | 41 | class ScheduleForm(forms.Form): 42 | slug = forms.CharField(max_length=50) 43 | 44 | def __init__(self, *args, **kwargs): 45 | super().__init__(*args, **kwargs) 46 | self.fields["slug"].widget.attrs["size"] = 12 47 | self.fields["slug"].widget.attrs["id"] = "s" 48 | 49 | def clean_slug(self): 50 | slug = slugify.slugify(self.cleaned_data["slug"]) 51 | if not slug: 52 | raise forms.ValidationError("Invalid value.") 53 | return slug 54 | -------------------------------------------------------------------------------- /plan/common/logger.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import logging 4 | 5 | from django.conf import settings 6 | 7 | DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" 8 | CONSOLE_LOG_FORMAT = "[%(asctime)s %(levelname)s] %(message)s" 9 | 10 | 11 | def init_console(level=None): 12 | if not level: 13 | level = getattr(settings, "LOGLEVEL", logging.INFO) 14 | 15 | logging.basicConfig( 16 | format=CONSOLE_LOG_FORMAT, datefmt=DATE_TIME_FORMAT, level=level 17 | ) 18 | -------------------------------------------------------------------------------- /plan/common/middleware.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import secrets 4 | import re 5 | 6 | from django import http, shortcuts, urls 7 | from django.conf import settings 8 | from django.utils import cache, translation 9 | from django.utils import http as http_utils 10 | from django.utils.deprecation import MiddlewareMixin 11 | from django.utils.translation import trans_real as trans_internals 12 | 13 | from plan.common.models import Semester 14 | 15 | RE_WHITESPACE = re.compile(rb"(\s\s+|\n)") 16 | 17 | 18 | class HtmlMinifyMiddleware(MiddlewareMixin): 19 | def should_minify(self, response): 20 | return ( 21 | settings.COMPRESS_ENABLED 22 | and response.status_code == 200 23 | and "text/html" in response["Content-Type"] 24 | ) 25 | 26 | def process_response(self, request, response): 27 | if self.should_minify(response): 28 | response.content = RE_WHITESPACE.sub(b" ", response.content) 29 | response.headers["Content-Length"] = len(response.content) 30 | return response 31 | 32 | 33 | class CspMiddleware(MiddlewareMixin): 34 | def process_request(self, request): 35 | request._csp_nonce = secrets.token_urlsafe(16) 36 | 37 | def process_response(self, request, response): 38 | if response.status_code in (404, 500) and settings.DEBUG: 39 | return response 40 | 41 | if "html" not in response.get("Content-Type", ""): 42 | return response 43 | 44 | policy = [ 45 | "default-src 'self'", 46 | f"script-src 'self' 'nonce-{request._csp_nonce}'", 47 | f"style-src 'self' 'nonce-{request._csp_nonce}'", 48 | "img-src 'self' data:", 49 | "frame-ancestors *", 50 | ] 51 | 52 | if settings.TIMETABLE_REPORT_URI: 53 | response["Reporting-Endpoints"] = ( 54 | f'endpoint="{settings.TIMETABLE_REPORT_URI}"' 55 | ) 56 | policy += [ 57 | f"report-uri {settings.TIMETABLE_REPORT_URI}", 58 | "report-to endpoint", 59 | ] 60 | 61 | response["Content-Security-Policy"] = " ; ".join(policy) 62 | return response 63 | 64 | 65 | class AppendSlashMiddleware(MiddlewareMixin): 66 | def process_request(self, request): 67 | # Bail if we already have trailing slash. 68 | if request.path.endswith("/"): 69 | return 70 | 71 | urlconf = getattr(request, "urlconf", None) 72 | old_is_valid = lambda: urls.is_valid_path(request.path_info, urlconf) 73 | new_is_valid = lambda: urls.is_valid_path("%s/" % request.path_info, urlconf) 74 | 75 | # Bail for valid urls or slash version not being valid. 76 | if old_is_valid() or not new_is_valid(): 77 | return 78 | 79 | if settings.DEBUG and request.method == "POST": 80 | raise RuntimeError("Can't redirect POST in AppendSlashMiddleware.") 81 | 82 | # Redirect rest: 83 | url = http_utils.urlquote("%s/" % request.path_info) 84 | if request.META.get("QUERY_STRING", ""): 85 | url += "?" + request.META["QUERY_STRING"] 86 | return http.HttpResponsePermanentRedirect(url) 87 | 88 | 89 | class LocaleMiddleware(MiddlewareMixin): 90 | def __init__(self, get_response): 91 | self.get_response = get_response 92 | 93 | self.languages = {} # Localised semester type -> lang 94 | self.values = {} # Localised semester type -> db value 95 | 96 | for lang, name in settings.LANGUAGES: 97 | with translation.override(lang): 98 | for value, slug in Semester.SEMESTER_SLUG: 99 | self.languages[str(slug)] = lang 100 | self.values[str(slug)] = value 101 | 102 | def process_view(self, request, view, args, kwargs): 103 | if "semester_type" not in kwargs: 104 | language = self.guess_language_from_accept_header(request) 105 | else: 106 | # Use semester type to set language, and convert localised value to 107 | # db value. 108 | try: 109 | semester = kwargs["semester_type"] 110 | language = self.languages[semester] 111 | kwargs["semester_type"] = self.values[semester] 112 | except KeyError: 113 | raise http.Http404 114 | 115 | if request.META["QUERY_STRING"] in dict(settings.LANGUAGES): 116 | return self.rederict_to_new_language(request, args, kwargs) 117 | 118 | with translation.override(language, deactivate=True): 119 | response = view(request, *args, **kwargs) 120 | response["Content-Language"] = language 121 | 122 | if "semester_type" not in kwargs: 123 | # Only set vary header when we had to guess. 124 | cache.patch_vary_headers(response, ("Accept-Language",)) 125 | 126 | return response 127 | 128 | def guess_language_from_accept_header(self, request): 129 | supported = dict(settings.LANGUAGES) 130 | accept = request.headers.get("Accept-Language", "") 131 | 132 | for lang, unused in trans_internals.parse_accept_lang_header(accept): 133 | if lang == "*": 134 | break 135 | lang = lang.split("-")[0].lower() 136 | if settings.LANGUAGE_FALLBACK.get(lang, lang) in supported: 137 | return lang 138 | return settings.LANGUAGE_CODE 139 | 140 | def rederict_to_new_language(self, request, args, kwargs): 141 | # Support ?lang etc, if this is present we activate the lang and 142 | # resolve the current url to get its name and reverse it with a 143 | # localised semester type. 144 | with translation.override(request.META["QUERY_STRING"], deactivate=True): 145 | match = urls.resolve(request.path_info) 146 | kwargs["semester_type"] = dict(Semester.SEMESTER_SLUG)[ 147 | kwargs["semester_type"] 148 | ] 149 | return shortcuts.redirect(match.url_name, *args, **kwargs) 150 | -------------------------------------------------------------------------------- /plan/common/migrations/0002_location.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.27 on 2020-04-04 11:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("common", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Location", 14 | fields=[ 15 | ( 16 | "id", 17 | models.AutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ( 25 | "name", 26 | models.CharField( 27 | max_length=100, unique=True, verbose_name="Location" 28 | ), 29 | ), 30 | ], 31 | options={ 32 | "verbose_name": "Location", 33 | "verbose_name_plural": "Locations", 34 | }, 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /plan/common/migrations/0003_course_locations.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.27 on 2020-04-04 11:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("common", "0002_location"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="course", 14 | name="locations", 15 | field=models.ManyToManyField(to="common.Location"), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /plan/common/migrations/0004_set_default_location.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def forwards(apps, schema_editor): 5 | Course = apps.get_model("common", "Course") 6 | Location = apps.get_model("common", "Location") 7 | 8 | Location.objects.create(name="Trondheim").course_set.set(Course.objects.all()) 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | ("common", "0003_course_locations"), 14 | ] 15 | 16 | operations = [ 17 | migrations.RunPython(forwards), 18 | ] 19 | -------------------------------------------------------------------------------- /plan/common/migrations/0005_lecture_title.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.27 on 2020-04-04 12:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("common", "0004_set_default_location"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="lecture", 14 | name="title", 15 | field=models.TextField(null=True, verbose_name="Title"), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /plan/common/migrations/0006_auto_20210314_1651.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.27 on 2021-03-14 16:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("common", "0005_lecture_title"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="room", 14 | name="url", 15 | field=models.URLField(default=b"", max_length=500, verbose_name="URL"), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /plan/common/migrations/0007_update_url_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-04-17 16:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("common", "0006_auto_20210314_1651"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="exam", 14 | name="url", 15 | field=models.URLField(default="", verbose_name="URL"), 16 | ), 17 | migrations.AlterField( 18 | model_name="group", 19 | name="url", 20 | field=models.URLField(default="", verbose_name="URL"), 21 | ), 22 | migrations.AlterField( 23 | model_name="room", 24 | name="url", 25 | field=models.URLField(default="", max_length=500, verbose_name="URL"), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /plan/common/migrations/0008_update_semester_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-04-17 16:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("common", "0007_update_url_fields"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="semester", 14 | name="type", 15 | field=models.CharField( 16 | choices=[("spring", "spring"), ("fall", "fall")], 17 | max_length=10, 18 | verbose_name="Type", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /plan/common/migrations/0009_update_id_types.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-04-17 16:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("common", "0008_update_semester_type"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="course", 14 | name="id", 15 | field=models.AutoField(primary_key=True, serialize=False), 16 | ), 17 | migrations.AlterField( 18 | model_name="deadline", 19 | name="id", 20 | field=models.AutoField(primary_key=True, serialize=False), 21 | ), 22 | migrations.AlterField( 23 | model_name="exam", 24 | name="id", 25 | field=models.AutoField(primary_key=True, serialize=False), 26 | ), 27 | migrations.AlterField( 28 | model_name="examtype", 29 | name="id", 30 | field=models.AutoField(primary_key=True, serialize=False), 31 | ), 32 | migrations.AlterField( 33 | model_name="group", 34 | name="id", 35 | field=models.AutoField(primary_key=True, serialize=False), 36 | ), 37 | migrations.AlterField( 38 | model_name="lecture", 39 | name="id", 40 | field=models.AutoField(primary_key=True, serialize=False), 41 | ), 42 | migrations.AlterField( 43 | model_name="lecturer", 44 | name="id", 45 | field=models.AutoField(primary_key=True, serialize=False), 46 | ), 47 | migrations.AlterField( 48 | model_name="lecturetype", 49 | name="id", 50 | field=models.AutoField(primary_key=True, serialize=False), 51 | ), 52 | migrations.AlterField( 53 | model_name="location", 54 | name="id", 55 | field=models.AutoField(primary_key=True, serialize=False), 56 | ), 57 | migrations.AlterField( 58 | model_name="room", 59 | name="id", 60 | field=models.AutoField(primary_key=True, serialize=False), 61 | ), 62 | migrations.AlterField( 63 | model_name="semester", 64 | name="id", 65 | field=models.AutoField(primary_key=True, serialize=False), 66 | ), 67 | migrations.AlterField( 68 | model_name="student", 69 | name="id", 70 | field=models.AutoField(primary_key=True, serialize=False), 71 | ), 72 | migrations.AlterField( 73 | model_name="subscription", 74 | name="id", 75 | field=models.AutoField(primary_key=True, serialize=False), 76 | ), 77 | migrations.AlterField( 78 | model_name="week", 79 | name="id", 80 | field=models.AutoField(primary_key=True, serialize=False), 81 | ), 82 | ] 83 | -------------------------------------------------------------------------------- /plan/common/migrations/0010_update_url_type_to_text_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-04-17 16:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("common", "0009_update_id_types"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="course", 14 | name="url", 15 | field=models.TextField(verbose_name="URL"), 16 | ), 17 | migrations.AlterField( 18 | model_name="exam", 19 | name="url", 20 | field=models.TextField(default="", verbose_name="URL"), 21 | ), 22 | migrations.AlterField( 23 | model_name="group", 24 | name="url", 25 | field=models.TextField(default="", verbose_name="URL"), 26 | ), 27 | migrations.AlterField( 28 | model_name="room", 29 | name="url", 30 | field=models.TextField(default="", verbose_name="URL"), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /plan/common/migrations/0011_add_summary_and_stream.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2025-01-11 19:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('common', '0010_update_url_type_to_text_field'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='lecture', 15 | name='stream', 16 | field=models.TextField(null=True, verbose_name='Stream'), 17 | ), 18 | migrations.AddField( 19 | model_name='lecture', 20 | name='summary', 21 | field=models.TextField(null=True, verbose_name='Summary'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /plan/common/migrations/0012_add_last_modified.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.25 on 2025-01-11 21:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('common', '0011_add_summary_and_stream'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='course', 15 | name='last_modified', 16 | field=models.DateTimeField(null=True, verbose_name='Last modified'), 17 | ), 18 | migrations.AddField( 19 | model_name='exam', 20 | name='last_modified', 21 | field=models.DateTimeField(null=True, verbose_name='Last modified'), 22 | ), 23 | migrations.AddField( 24 | model_name='lecture', 25 | name='last_modified', 26 | field=models.DateTimeField(null=True, verbose_name='Last modified'), 27 | ), 28 | migrations.AddField( 29 | model_name='room', 30 | name='last_modified', 31 | field=models.DateTimeField(null=True, verbose_name='Last modified'), 32 | ), 33 | migrations.AddField( 34 | model_name='subscription', 35 | name='last_modified', 36 | field=models.DateTimeField(auto_now=True, verbose_name='Modified'), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /plan/common/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/common/migrations/__init__.py -------------------------------------------------------------------------------- /plan/common/sprite.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # This file is part of the plan timetable generator, see LICENSE for details. 3 | 4 | """Minimal binary to generate and optimize sprites for css.""" 5 | 6 | import argparse 7 | import os 8 | import shutil 9 | import subprocess 10 | import tempfile 11 | 12 | from PIL import Image 13 | 14 | BASE_CSS = """ 15 | [class^="%(prefix)s"], 16 | [class*=" %(prefix)s"] { 17 | display: inline-block; 18 | width: %(size)spx; 19 | height: %(size)spx; 20 | line-height: %(size)spx; 21 | vertical-align: text-top; 22 | background: url("%(output)s") no-repeat; 23 | } 24 | """ 25 | BASE_TMPL = ".%(prefix)s%%s { background-position: %%dpx %%dpx; }\n" 26 | 27 | 28 | if __name__ == "__main__": 29 | parser = argparse.ArgumentParser() 30 | parser.add_argument("--padding", type=int, default=3) 31 | parser.add_argument("--size", type=int, default=16, help="Image width and height") 32 | parser.add_argument("--rows", type=int, default=5, help="Number of rows in grid") 33 | parser.add_argument("--prefix", default="icon-", help="CSS prefix") 34 | parser.add_argument("--output", default="sprite.png", help="Output file") 35 | parser.add_argument( 36 | "--compress", default="none", choices=("pngcrush", "optipng", "none") 37 | ) 38 | parser.add_argument("input", nargs="+", help="Input files") 39 | 40 | args = parser.parse_args() 41 | 42 | padding = args.padding 43 | size = args.size + padding 44 | rows = args.rows 45 | output = os.path.abspath(args.output) 46 | files = list(map(os.path.abspath, args.input)) 47 | grid = [] 48 | 49 | context = {"prefix": args.prefix, "size": args.size, "output": args.output} 50 | 51 | css = BASE_CSS % context 52 | tmpl = BASE_TMPL % context 53 | 54 | if output in files: 55 | files.remove(output) 56 | 57 | while files: 58 | grid.append(files[:rows]) 59 | files = files[rows:] 60 | 61 | width, height = (size * len(grid) - padding, size * rows - padding) 62 | sprite = Image.new(mode="RGBA", size=(width, height), color=(0, 0, 0, 0)) 63 | 64 | for i, row in enumerate(grid): 65 | for j, path in enumerate(row): 66 | x, y = i * size, j * size 67 | sprite.paste(Image.open(path), (x, y)) 68 | 69 | name = os.path.splitext(os.path.basename(path))[0] 70 | css += tmpl % (name, -x, -y) 71 | 72 | tmp = tempfile.NamedTemporaryFile(suffix=os.path.splitext(output)[1]) 73 | sprite.save(tmp.name) 74 | 75 | original_size = os.stat(tmp.name).st_size 76 | 77 | if args.compress == "pngcrush": 78 | subprocess.call(["pngcrush", "-rem", "alla-reduce", "-brute", tmp.name, output]) 79 | elif args.compress == "optipng": 80 | if os.path.exists(output): 81 | os.remove(output) 82 | subprocess.call( 83 | [ 84 | "optipng", 85 | "-zc1-9", 86 | "-zm1-9", 87 | "-zs0-3", 88 | "-f0-5", 89 | "-o7", 90 | "-out", 91 | output, 92 | tmp.name, 93 | ] 94 | ) 95 | else: 96 | shutil.copyfile(tmp.name, output) 97 | 98 | final_size = os.stat(output).st_size 99 | 100 | print("/* -- sprite css rules -- */") 101 | print(css) 102 | print("/* -- done -- */") 103 | 104 | print( 105 | "Original size: {}, final size: {}. {:.3f}% improvment.".format( 106 | original_size, final_size, 100 - final_size * 100.0 / original_size 107 | ) 108 | ) 109 | -------------------------------------------------------------------------------- /plan/common/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/common/templatetags/__init__.py -------------------------------------------------------------------------------- /plan/common/templatetags/color.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from django import template 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.tag(name="color") 9 | def do_color(parser, token): 10 | try: 11 | tag_name, value = token.split_contents() 12 | except ValueError: 13 | raise template.TemplateSyntaxError( 14 | "%r tag requires a single argument" % token.contents.split()[0] 15 | ) 16 | 17 | if value[0] == value[-1] and value[0] in ('"', "'"): 18 | raise template.TemplateSyntaxError( 19 | "%r tag's argument should not be in quotes" % tag_name 20 | ) 21 | 22 | return ColorNode(value) 23 | 24 | 25 | class ColorNode(template.Node): 26 | def __init__(self, value): 27 | self.value = template.Variable(value) 28 | self.color_map = template.Variable("color_map") 29 | 30 | def render(self, context): 31 | try: 32 | return self.color_map.resolve(context)[self.value.resolve(context)] 33 | except template.VariableDoesNotExist: 34 | return "" 35 | -------------------------------------------------------------------------------- /plan/common/templatetags/compact.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from django import template 4 | 5 | from plan.common.utils import compact_sequence 6 | 7 | register = template.Library() 8 | 9 | register.filter("compact", compact_sequence) 10 | -------------------------------------------------------------------------------- /plan/common/templatetags/get.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from django import template 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.filter 9 | def get(value, key): 10 | return value.get(key, "") 11 | -------------------------------------------------------------------------------- /plan/common/templatetags/hostname.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import urllib.parse 4 | 5 | from django import template 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.filter 11 | def hostname(value): 12 | result = urllib.parse.urlparse(value) 13 | if not result: 14 | return None 15 | elif result.hostname.startswith("www."): 16 | return result.hostname[4:] 17 | else: 18 | return result.hostname 19 | -------------------------------------------------------------------------------- /plan/common/templatetags/nbsp.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from django import template 4 | from django.utils import html, safestring 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.filter 10 | def nbsp(string): 11 | return safestring.mark_safe(html.conditional_escape(string).replace(" ", " ")) 12 | 13 | 14 | nbsp.is_safe = True 15 | -------------------------------------------------------------------------------- /plan/common/templatetags/nonce.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | 4 | from django import template 5 | from lxml import html 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.tag(name="nonce") 11 | def do_nonce(parser, token): 12 | try: 13 | name, nonce = token.split_contents() 14 | except ValuerError: 15 | raise template.TemplateSyntaxError("'nonce' tag requires a single argument") 16 | 17 | nodelist = parser.parse(("endnonce",)) 18 | parser.delete_first_token() 19 | return Nonce(nonce, nodelist) 20 | 21 | 22 | class Nonce(template.Node): 23 | def __init__(self, nonce, nodelist) -> None: 24 | self.nonce = template.Variable(nonce) 25 | self.nodelist = nodelist 26 | 27 | def render(self, context): 28 | output = self.nodelist.render(context) 29 | nonce = self.nonce.resolve(context) 30 | 31 | tag = html.fragment_fromstring(output) 32 | if nonce: 33 | tag.attrib["nonce"] = nonce 34 | return html.tostring(tag).decode("utf-8") 35 | -------------------------------------------------------------------------------- /plan/common/templatetags/slugify.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from django import template 4 | from django.template import defaultfilters 5 | 6 | register = template.Library() 7 | 8 | REPLACE_MAP = ( 9 | ("Æ", "Ae"), 10 | ("Ø", "O"), 11 | ("Å", "Aa"), 12 | ("æ", "ae"), 13 | ("ø", "o"), 14 | ("å", "aa"), 15 | ) 16 | 17 | 18 | @register.filter 19 | @defaultfilters.stringfilter 20 | def slugify(text): 21 | for old, new in REPLACE_MAP: 22 | text = text.replace(old, new) 23 | return defaultfilters.slugify(text) 24 | 25 | 26 | slugify.is_safe = True 27 | -------------------------------------------------------------------------------- /plan/common/templatetags/strip.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import re 4 | 5 | from django import template 6 | from django.template import defaultfilters 7 | 8 | register = template.Library() 9 | 10 | 11 | @register.filter 12 | @defaultfilters.stringfilter 13 | def striphttp(value): 14 | return re.sub(r"^https?://(www\.)?", "", value) 15 | 16 | 17 | @register.tag(name="stripspace") 18 | def do_stripspace(parser, token): 19 | nodelist = parser.parse(("endstripspace",)) 20 | parser.delete_first_token() 21 | return StripNode(nodelist) 22 | 23 | 24 | class StripNode(template.Node): 25 | regexp = re.compile(r"\s+") 26 | 27 | def __init__(self, nodelist): 28 | self.nodelist = nodelist 29 | 30 | def render(self, context): 31 | output = self.nodelist.render(context) 32 | return re.sub(self.regexp, " ", output).strip() 33 | -------------------------------------------------------------------------------- /plan/common/templatetags/tabindex.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | 4 | from django import template 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.filter 10 | def tabindex(value, index): 11 | value.field.widget.attrs["tabindex"] = index 12 | return value 13 | -------------------------------------------------------------------------------- /plan/common/templatetags/title.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import re 4 | 5 | from django import template 6 | from django.utils import translation 7 | 8 | register = template.Library() 9 | 10 | 11 | @register.inclusion_tag("title.html") 12 | def title(semester, slug, week=None): 13 | # TODO(adamcik): feels wrong hardcoding this here. 14 | if translation.get_language() in ["no", "nb", "nn"]: 15 | ending = norwegian(slug) 16 | else: 17 | ending = english(slug) 18 | 19 | return { 20 | "slug": slug, 21 | "ending": ending, 22 | "type": semester.get_type_display(), 23 | "year": semester.year, 24 | "week": week, 25 | } 26 | 27 | 28 | def render_title(semester, slug, week=None): 29 | title_template = template.loader.get_template("title.html") 30 | context = title(semester, slug, week) 31 | rendered = title_template.render(context) 32 | rendered = rendered.replace("\n", " ") 33 | rendered = re.sub(r"\s+", " ", rendered) 34 | return rendered.strip() 35 | 36 | 37 | def english(slug): 38 | if slug.endswith("s"): 39 | return "'" 40 | return "'s" 41 | 42 | 43 | def norwegian(slug): 44 | if re.search(r"(z|s|x|sch|sh)$", slug): 45 | return "'" 46 | return "s" 47 | -------------------------------------------------------------------------------- /plan/common/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import datetime 4 | 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | 8 | from plan.common.models import Semester 9 | 10 | 11 | # TODO(adamcik): switch to proper mock lib. 12 | class BaseTestCase(TestCase): 13 | def setUp(self): 14 | self.set_now_to(2009, 1, 1) 15 | 16 | self.semester = Semester(year=2009, type="spring") 17 | self.default_args = [self.semester.year, self.semester.type, "adamcik"] 18 | 19 | def set_now_to(self, year, month, day): 20 | from plan.common import models, views 21 | 22 | dt = datetime.datetime(year, month, day) 23 | 24 | for cls in models, views: 25 | cls.now = lambda: dt 26 | cls.today = lambda: dt.date() 27 | 28 | def url(self, name, *args): 29 | if args: 30 | return reverse(name, args=args) 31 | else: 32 | return reverse(name, args=self.default_args) 33 | 34 | def url_basic(self, name): 35 | return reverse(name) 36 | -------------------------------------------------------------------------------- /plan/common/tests/test_managers.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.common.models import Course, Exam, Lecture, Semester, Subscription 4 | from plan.common.tests import BaseTestCase 5 | 6 | 7 | class ManagerTestCase(BaseTestCase): 8 | fixtures = ["test_data.json", "test_user.json"] 9 | 10 | def test_get_lectures(self): 11 | # Exclude lectures connected to other courses and excluded from userset 12 | control = Lecture.objects.exclude(id__in=[6, 7]) 13 | 14 | # Try showing all lectures 15 | lectures = Lecture.objects.get_lectures(2009, Semester.SPRING, "adamcik") 16 | lectures = [l for l in lectures if l.show_week and not l.exclude] 17 | self.assertEqual(set(lectures), set(control)) 18 | 19 | # Try showing only lectures in week 1 20 | lectures = Lecture.objects.get_lectures(2009, Semester.SPRING, "adamcik", 1) 21 | lectures = [l for l in lectures if l.show_week and not l.exclude] 22 | self.assertEqual(set(lectures), set(control.filter(weeks__number=1))) 23 | 24 | # Try showing lectures in week 2 25 | lectures = Lecture.objects.get_lectures(2009, Semester.SPRING, "adamcik", 2) 26 | lectures = [l for l in lectures if l.show_week and not l.exclude] 27 | self.assertEqual(set(lectures), set(control.filter(weeks__number=2))) 28 | 29 | # Try lectures in week 3, ie none 30 | lectures = Lecture.objects.get_lectures(2009, Semester.SPRING, "adamcik", 3) 31 | lectures = [l for l in lectures if l.show_week and not l.exclude] 32 | self.assertEqual(set(lectures), set()) 33 | 34 | def test_get_exams(self): 35 | exams = Exam.objects.get_exams(2009, Semester.SPRING, "adamcik") 36 | self.assertEqual(set(exams), set(Exam.objects.exclude(id__in=[3, 4]))) 37 | 38 | def test_get_courses(self): 39 | courses = Course.objects.get_courses(2009, Semester.SPRING, "adamcik") 40 | self.assertEqual(set(Course.objects.exclude(id__in=[4, 5])), set(courses)) 41 | 42 | def test_get_courses_with_exams(self): 43 | courses = Course.objects.get_courses_with_exams(2009, Semester.SPRING) 44 | courses = [a[0] for a in courses] 45 | 46 | # Ensure that courses without exams are included and courses with 47 | # multiple exams on time per exam 48 | self.assertEqual(courses, [1, 1, 1, 1, 2, 3, 4, 4]) 49 | 50 | def test_get_subscriptions(self): 51 | control = Subscription.objects.filter(id__in=[1, 2, 3]) 52 | subscriptions = Subscription.objects.get_subscriptions( 53 | 2009, Semester.SPRING, "adamcik" 54 | ) 55 | self.assertEqual(set(control), set(subscriptions)) 56 | 57 | def test_search(self): 58 | control = Course.objects.exclude(id=5) 59 | courses = Course.objects.search(2009, Semester.SPRING, "COURSE") 60 | 61 | self.assertEqual(set(control), set(courses)) 62 | 63 | control = Course.objects.filter(code="COURSE1") 64 | courses = Course.objects.search(2009, Semester.SPRING, "COURSE1") 65 | 66 | self.assertEqual(set(control), set(courses)) 67 | -------------------------------------------------------------------------------- /plan/common/tests/test_models.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.common.models import Course, Semester 4 | from plan.common.tests import BaseTestCase 5 | 6 | 7 | class ModelsTestCase(BaseTestCase): 8 | fixtures = ["test_data.json", "test_user.json"] 9 | 10 | def test_course_get_stats(self): 11 | semester = Semester.objects.get(year=2009, type=Semester.SPRING) 12 | actual = Course.get_stats(semester) 13 | 14 | self.assertEqual(3, actual.pop("slug_count")) 15 | self.assertEqual(3, actual.pop("course_count")) 16 | self.assertEqual(6, actual.pop("subscription_count")) 17 | 18 | stats = actual.pop("stats") 19 | 20 | self.assertEqual((3, 2, "COURSE2", "Course 2 full name"), stats[0]) 21 | self.assertEqual((2, 1, "COURSE1", "Course 1 full name"), stats[1]) 22 | self.assertEqual((1, 3, "COURSE3", "Course 3 full name"), stats[2]) 23 | 24 | # FIXME test unicode 25 | # FIXME test course.get_url 26 | # FIXME test get_stats(int) 27 | # FIXME test semester.init customisation 28 | -------------------------------------------------------------------------------- /plan/common/tests/test_timetable.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from copy import copy 4 | 5 | from plan.common.models import Lecture, Semester 6 | from plan.common.tests import BaseTestCase 7 | from plan.common.timetable import Timetable 8 | 9 | 10 | class TimetableTestCase(BaseTestCase): 11 | maxDiff = None 12 | fixtures = ["test_data.json", "test_user.json"] 13 | 14 | def test_timetable(self): 15 | # FIXME test expansion 16 | # FIXME test instert times 17 | # FIXME test map_to_slot 18 | 19 | lectures = Lecture.objects.get_lectures(2009, Semester.SPRING, "adamcik") 20 | 21 | timetable = Timetable(lectures) 22 | timetable.place_lectures() 23 | timetable.add_markers() 24 | 25 | rows = [] 26 | bottom = {"bottom": True} 27 | last = {"last": True} 28 | bottomlast = {"bottom": True, "last": True} 29 | 30 | lectures = {l.id: l for l in lectures} 31 | lecture2 = { 32 | "lecture": lectures[2], 33 | "rowspan": 2, 34 | "remove": False, 35 | "bottom": False, 36 | } 37 | lecture3 = { 38 | "lecture": lectures[3], 39 | "rowspan": 2, 40 | "remove": False, 41 | "bottom": False, 42 | } 43 | lecture4 = { 44 | "lecture": lectures[4], 45 | "rowspan": 6, 46 | "remove": False, 47 | "bottom": False, 48 | } 49 | lecture5 = { 50 | "lecture": lectures[5], 51 | "rowspan": 2, 52 | "remove": False, 53 | "bottom": False, 54 | "last": True, 55 | } 56 | lecture8 = { 57 | "lecture": lectures[8], 58 | "rowspan": 1, 59 | "remove": False, 60 | "bottom": False, 61 | } 62 | lecture9 = { 63 | "lecture": lectures[9], 64 | "rowspan": 12, 65 | "remove": False, 66 | "bottom": True, 67 | "last": True, 68 | } 69 | lecture10 = { 70 | "lecture": lectures[10], 71 | "rowspan": 1, 72 | "remove": False, 73 | "bottom": False, 74 | "last": True, 75 | } 76 | lecture11 = { 77 | "lecture": lectures[11], 78 | "rowspan": 1, 79 | "remove": False, 80 | "bottom": True, 81 | "last": True, 82 | } 83 | 84 | rows.append( 85 | [[lecture2, lecture4, last], [lecture9], [lecture10], [last], [last]] 86 | ) 87 | 88 | lecture2 = copy(lecture2) 89 | lecture2["remove"] = True 90 | lecture9 = copy(lecture9) 91 | lecture9["remove"] = True 92 | lecture9["bottom"] = False 93 | 94 | lecture4 = copy(lecture4) 95 | lecture4["remove"] = True 96 | 97 | rows.append( 98 | [[lecture2, lecture4, lecture5], [lecture9], [last], [last], [last]] 99 | ) 100 | 101 | lecture5 = copy(lecture5) 102 | lecture5["remove"] = True 103 | 104 | rows.append( 105 | [[lecture3, lecture4, lecture5], [lecture9], [last], [last], [last]] 106 | ) 107 | 108 | lecture3 = copy(lecture3) 109 | lecture3["remove"] = True 110 | 111 | rows.append([[lecture3, lecture4, last], [lecture9], [last], [last], [last]]) 112 | rows.append([[{}, lecture4, last], [lecture9], [last], [last], [last]]) 113 | rows.append([[{}, lecture4, last], [lecture9], [last], [last], [last]]) 114 | rows.append([[{}, {}, last], [lecture9], [last], [last], [last]]) 115 | rows.append([[lecture8, {}, last], [lecture9], [last], [last], [last]]) 116 | rows.append([[{}, {}, last], [lecture9], [last], [last], [last]]) 117 | rows.append([[{}, {}, last], [lecture9], [last], [last], [last]]) 118 | rows.append([[{}, {}, last], [lecture9], [last], [last], [last]]) 119 | rows.append( 120 | [ 121 | [bottom, bottom, bottomlast], 122 | [lecture9], 123 | [lecture11], 124 | [bottomlast], 125 | [bottomlast], 126 | ] 127 | ) 128 | 129 | for i, (t, r) in enumerate(zip(timetable.table, rows)): 130 | self.assertEqual(t, r) 131 | -------------------------------------------------------------------------------- /plan/common/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from django.conf import settings 4 | 5 | from plan.common.tests import BaseTestCase 6 | from plan.common.utils import ColorMap, compact_sequence 7 | 8 | 9 | class UtilTestCase(BaseTestCase): 10 | fixtures = ["test_data.json"] 11 | 12 | def test_colormap(self): 13 | c = ColorMap() 14 | keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 3, 5, 6] 15 | for k in keys: 16 | self.assertEqual(c[k], "color%d" % (k % c.max)) 17 | 18 | c = ColorMap(hex=True) 19 | for k in keys: 20 | self.assertEqual(c[k], settings.TIMETABLE_COLORS[k % c.max]) 21 | 22 | self.assertEqual(c[None], "") 23 | 24 | def test_compact_sequence(self): 25 | seq = compact_sequence([1, 2, 3, 5, 6, 7, 8, 12, 13, 15, 17, 19]) 26 | self.assertEqual(seq, ["1-3", "5-8", "12-13", "15", "17", "19"]) 27 | 28 | seq = compact_sequence([1, 2, 3]) 29 | self.assertEqual(seq, ["1-3"]) 30 | 31 | seq = compact_sequence([1, 3]) 32 | self.assertEqual(seq, ["1", "3"]) 33 | 34 | seq = compact_sequence([]) 35 | self.assertEqual(seq, []) 36 | -------------------------------------------------------------------------------- /plan/common/timetable.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import datetime 4 | 5 | from django.conf import settings 6 | from django.utils import formats 7 | 8 | from plan.common import utils 9 | from plan.common.models import Lecture 10 | 11 | SLOT_END_TIMES = [s[1] for s in settings.TIMETABLE_SLOTS] 12 | 13 | 14 | class Timetable: 15 | slots = len(settings.TIMETABLE_SLOTS) 16 | 17 | def __init__(self, lectures): 18 | self.lecture_queryset = lectures 19 | self.lectures = [] 20 | self.table = [[[{}] for a in Lecture.DAYS] for b in range(self.slots)] 21 | self.span = [1] * len(Lecture.DAYS) 22 | self.date = [None] * len(Lecture.DAYS) 23 | 24 | def header(self): 25 | for i, name in Lecture.DAYS: 26 | yield self.span[i], self.date[i], name 27 | 28 | def set_week(self, year, week): 29 | first_day = utils.first_date_in_week(year, week) 30 | self.date = [ 31 | (first_day + datetime.timedelta(days=i)) for i, name in Lecture.DAYS 32 | ] 33 | 34 | def place_lectures(self): 35 | """Add basics to datastructure""" 36 | 37 | for i, lecture in enumerate(self.lecture_queryset): 38 | if lecture.exclude or not lecture.show_week: 39 | continue 40 | 41 | start, end = self.map_to_slot(lecture) 42 | rowspan = end - start + 1 43 | 44 | first = start 45 | 46 | # Try to find leftmost row that can fit our lecture, if we run out of 47 | # rows to test, ie IndexError, we append a fresh one to work with 48 | try: 49 | row = 0 50 | while start <= end: 51 | if self.table[start][lecture.day][row]: 52 | # One of our time slots is taken, bump the row number and 53 | # restart our search 54 | row += 1 55 | start = first 56 | else: 57 | start += 1 58 | 59 | except IndexError: 60 | # We ran out of rows to check, simply append a new row 61 | for j in range(self.slots): 62 | self.table[j][lecture.day].append({}) 63 | 64 | # Update the header colspan 65 | self.span[lecture.day] += 1 66 | 67 | start = first 68 | remove = False 69 | 70 | while start <= end: 71 | # Replace the cell we found with a base containing info about our 72 | # lecture 73 | self.table[start][lecture.day][row] = { 74 | "lecture": lecture, 75 | "rowspan": rowspan, 76 | "remove": remove, 77 | "bottom": start + rowspan == len(self.table), 78 | } 79 | 80 | # Add lecture to our supplementary data structure and set the 81 | # remove flag. 82 | if not remove: 83 | remove = True 84 | self.lectures.append( 85 | { 86 | "height": rowspan, 87 | "i": start, 88 | "j": lecture.day, 89 | "k": row, 90 | "l": lecture, 91 | } 92 | ) 93 | 94 | start += 1 95 | 96 | def do_expansion(self): 97 | for lecture in self.lectures: 98 | # Loop over supplementary data structure using this to figure out which 99 | # colspan expansions are safe 100 | i = lecture["i"] 101 | j = lecture["j"] 102 | k = lecture["k"] 103 | 104 | height = lecture["height"] 105 | 106 | expand_by = 1 107 | 108 | # Find safe expansion of colspan 109 | safe = True 110 | for l in range(k + 1, len(self.table[i][j])): 111 | for m in range(i, i + height): 112 | if self.table[m][j][l]: 113 | safe = False 114 | break 115 | if safe: 116 | expand_by += 1 117 | else: 118 | break 119 | 120 | self.table[i][j][k]["colspan"] = expand_by 121 | lecture["width"] = expand_by 122 | 123 | if k + expand_by == len(self.table[i][j]): 124 | self.table[i][j][k]["last"] = True 125 | 126 | # Remove cells that will get replaced by colspan 127 | for l in range(k + 1, k + expand_by): 128 | for m in range(i, i + height): 129 | self.table[m][j][l]["remove"] = True 130 | 131 | def add_markers(self): 132 | for row in self.table: 133 | for day in row: 134 | day[-1]["last"] = True 135 | for day in self.table[-1]: 136 | for cell in day: 137 | # only bother with cells that will be shown. 138 | cell["bottom"] = not cell.get("remove", False) 139 | 140 | def insert_times(self): 141 | for i, slot in enumerate(settings.TIMETABLE_SLOTS): 142 | start = formats.time_format(slot[0]) 143 | end = formats.time_format(slot[1]) 144 | self.table[i].insert(0, [{"time": f"{start} - {end}"}]) 145 | 146 | def map_to_slot(self, lecture): 147 | start, end = None, None 148 | 149 | for i, time in enumerate(SLOT_END_TIMES): 150 | if start is None and lecture.start < time: 151 | start = i 152 | 153 | if end is None and lecture.end <= time: 154 | end = i 155 | 156 | if end is None and lecture.end > time: 157 | end = i 158 | 159 | message = "%s slot for %s could not be set." 160 | assert start is not None, message % ("Start", lecture.id) 161 | assert end is not None, message % ("End", lecture.id) 162 | 163 | return (start, end) 164 | -------------------------------------------------------------------------------- /plan/common/urls.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.common.utils import url_helper 4 | from plan.common.views import * 5 | 6 | urlpatterns = [ 7 | url_helper(r"^$", frontpage, name="frontpage"), 8 | url_helper(r"^{year}/{semester}/$", getting_started, name="semester"), 9 | url_helper(r"^{year}/{semester}/\+$", course_query, name="course-query"), 10 | url_helper( 11 | r"^{year}/{semester}/{slug}/$", schedule, {"all": True}, name="schedule" 12 | ), 13 | url_helper( 14 | r"^{year}/{semester}/{slug}/\+$", 15 | schedule, 16 | {"advanced": True}, 17 | name="schedule-advanced", 18 | ), 19 | url_helper( 20 | r"^{year}/{semester}/{slug}/current/$", 21 | schedule_current, 22 | name="schedule-current", 23 | ), 24 | url_helper(r"^{year}/{semester}/{slug}/{week}/$", schedule, name="schedule-week"), 25 | url_helper( 26 | r"^{year}/{semester}/{slug}/change/$", select_course, name="change-course" 27 | ), 28 | url_helper( 29 | r"^{year}/{semester}/{slug}/groups/$", select_groups, name="change-groups" 30 | ), 31 | url_helper( 32 | r"^{year}/{semester}/{slug}/filter/$", select_lectures, name="change-lectures" 33 | ), 34 | url_helper(r"^[+]$", about, name="about"), 35 | url_helper(r"^r/{id}/?$", room_redirect, name="room_redirect"), 36 | url_helper(r"^stats[+]$", api, name="api"), 37 | url_helper(r"^{slug}/?$", shortcut, name="shortcut"), 38 | ] 39 | -------------------------------------------------------------------------------- /plan/common/utils.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import datetime 4 | import operator 5 | import random 6 | import re 7 | import time 8 | import typing 9 | import typing_extensions 10 | 11 | from django import http, template 12 | from django.conf import settings, urls 13 | from django.core.cache import cache 14 | from django.db import models 15 | from django.utils import http as http_utils 16 | from django.utils import text as text_utils 17 | from django.utils import translation 18 | 19 | _ = translation.gettext 20 | 21 | # Collection of capture groups used in urls. 22 | URL_ALIASES = { 23 | "year": r"(?P\d{4})", 24 | "semester": r"(?P\w+)", 25 | "slug": r"(?P[a-z0-9-_]{1,50})", 26 | "week": r"(?P\d{1,2})", 27 | "size": r"(?PA\d)", 28 | "ical": r"(?P\w+)", 29 | "id": r"(?P\d+)", 30 | } 31 | 32 | 33 | def url_helper(regexp, *args, **kwargs): 34 | """Helper that inserts our url aliases using string formating.""" 35 | return urls.url(regexp.format(**URL_ALIASES), *args, **kwargs) 36 | 37 | 38 | def cache_headers(timeout: datetime.timedelta, jitter: float = 0.0) -> dict[str, str]: 39 | seconds = timeout.total_seconds() 40 | if jitter > 0: 41 | seconds += random.uniform(0, seconds * jitter) 42 | 43 | return { 44 | "Expires": http_utils.http_date(time.time() + seconds), 45 | "Cache-Control": "max-age=%d" % seconds, 46 | } 47 | 48 | 49 | def ical_filename(year, semester_type, slug, resources): 50 | return "%s.ics" % "-".join([year, semester_type, slug] + resources) 51 | 52 | 53 | def clear_cache(year, semester_type, slug): 54 | cache.delete_many( 55 | [ 56 | ical_filename(year, semester_type, slug, [_("lectures"), _("exams")]), 57 | ical_filename(year, semester_type, slug, [_("lectures")]), 58 | ical_filename(year, semester_type, slug, [_("exams")]), 59 | ] 60 | ) 61 | 62 | 63 | Params = typing_extensions.ParamSpec("Params") 64 | ViewDecorator = typing.Callable[Params, http.HttpResponse] 65 | 66 | 67 | def expires_in(timeout: datetime.timedelta): 68 | def decorator(func: ViewDecorator[Params]) -> ViewDecorator[Params]: 69 | def wrapper(*args: Params.args, **kwargs: Params.kwargs) -> http.HttpResponse: 70 | response = func(*args, **kwargs) 71 | for name, value in cache_headers(timeout).items(): 72 | response.headers[name] = value 73 | return response 74 | 75 | return wrapper 76 | 77 | return decorator 78 | 79 | 80 | def build_search(searchstring, filters, max_query_length=4, combine=operator.and_): 81 | count = 0 82 | search_filter = models.Q() 83 | 84 | for word in text_utils.smart_split(searchstring): 85 | if word[0] in ['"', "'"]: 86 | if word[0] == word[-1]: 87 | word = word[1:-1] 88 | else: 89 | word = word[1:] 90 | 91 | if count > max_query_length: 92 | break 93 | 94 | local_filter = models.Q() 95 | for f in filters: 96 | local_filter |= models.Q(**{f: word}) 97 | 98 | search_filter = combine(search_filter, local_filter) 99 | count += 1 100 | 101 | return search_filter 102 | 103 | 104 | def server_error(request, template_name="500.html"): 105 | """ 106 | 500 error handler. 107 | 108 | Templates: `500.html` 109 | Context: None 110 | """ 111 | # You need to create a 500.html template. 112 | t = template.loader.get_template(template_name) 113 | 114 | return http.HttpResponseServerError( 115 | t.render( 116 | { 117 | "MEDIA_URL": settings.MEDIA_URL, 118 | "STATIC_URL": settings.STATIC_URL, 119 | "SOURCE_URL": settings.TIMETABLE_SOURCE_URL, 120 | } 121 | ) 122 | ) 123 | 124 | 125 | def compact_sequence(sequence): 126 | """Compact sequences of numbers into array of strings [i, j, k-l, n-m]""" 127 | if not sequence: 128 | return [] 129 | 130 | sequence.sort() 131 | 132 | compact = [] 133 | first = sequence[0] 134 | last = sequence[0] - 1 135 | 136 | for item in sequence: 137 | if last == item - 1: 138 | last = item 139 | else: 140 | if first != last: 141 | compact.append("%d-%d" % (first, last)) 142 | else: 143 | compact.append("%d" % first) 144 | 145 | first = item 146 | last = item 147 | 148 | if first != last: 149 | compact.append("%d-%d" % (first, last)) 150 | else: 151 | compact.append("%d" % first) 152 | 153 | return compact 154 | 155 | 156 | class ColorMap(dict): 157 | """Magic dict that assigns colors""" 158 | 159 | # Colors from www.ColorBrewer.org by Cynthia A. Brewer, Geography, 160 | # Pennsylvania State University. 161 | # http://www.personal.psu.edu/cab38/ColorBrewer/ColorBrewer_updates.html 162 | 163 | def __init__(self, index=0, hex=False): 164 | self.index = index 165 | self.max = len(settings.TIMETABLE_COLORS) 166 | self.hex = hex 167 | 168 | def __getitem__(self, k): 169 | # Remember to use super to prevent inf loop 170 | if k is None: 171 | return "" 172 | 173 | if k in self: 174 | return super().__getitem__(k) 175 | else: 176 | self.index += 1 177 | if self.hex: 178 | self[k] = settings.TIMETABLE_COLORS[self.index % self.max] 179 | else: 180 | self[k] = "color%d" % (self.index % self.max) 181 | return super().__getitem__(k) 182 | 183 | 184 | def max_number_of_weeks(year): 185 | # dec. 28 is always on the last week if the year. 186 | return datetime.date(int(year), 12, 28).isocalendar()[1] 187 | 188 | 189 | def first_date_in_week(year, week): 190 | if datetime.date(year, 1, 4).isoweekday() > 4: 191 | return datetime.datetime.strptime("%d %d 1" % (year, week - 1), "%Y %W %w") 192 | else: 193 | return datetime.datetime.strptime("%d %d 1" % (year, week), "%Y %W %w") 194 | 195 | 196 | def natural_sort(values, key=None): 197 | if key is None: 198 | key = lambda k: k 199 | split = lambda v: re.split(r"(\d+)", v) if isinstance(v, str) else [v] 200 | convert = lambda v: int(v) if v.isdigit() else v.lower() 201 | return sorted( 202 | values, 203 | key=lambda v: [convert(p) if isinstance(p, str) else p for p in split(key(v))], 204 | ) 205 | -------------------------------------------------------------------------------- /plan/ical/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/ical/__init__.py -------------------------------------------------------------------------------- /plan/ical/models.py: -------------------------------------------------------------------------------- 1 | # Placeholder file since django apps must contain models.py 2 | -------------------------------------------------------------------------------- /plan/ical/tests.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.common import tests 4 | 5 | 6 | class EmptyViewTestCase(tests.BaseTestCase): 7 | def test_ical(self): 8 | url = self.url("schedule-ical") 9 | self.assertEqual(self.client.get(url).status_code, 204) 10 | 11 | for arg in ("exams", "lectures"): 12 | url_args = list(self.default_args) + [arg] 13 | url = self.url("schedule-ical", *url_args) 14 | self.assertEqual(self.client.get(url).status_code, 204) 15 | 16 | url_args = list(self.default_args) + ["foo"] 17 | url = self.url("schedule-ical", *url_args) 18 | self.assertEqual(self.client.get(url).status_code, 400) 19 | 20 | 21 | class ViewTestCase(tests.BaseTestCase): 22 | fixtures = ["test_data.json", "test_user.json"] 23 | 24 | def test_ical(self): 25 | url = self.url("schedule-ical") 26 | self.assertEqual(self.client.get(url).status_code, 200) 27 | 28 | for arg in ("exams", "lectures"): 29 | url_args = list(self.default_args) + [arg] 30 | url = self.url("schedule-ical", *url_args) 31 | self.assertEqual(self.client.get(url).status_code, 200) 32 | 33 | url_args = list(self.default_args) + ["foo"] 34 | url = self.url("schedule-ical", *url_args) 35 | self.assertEqual(self.client.get(url).status_code, 400) 36 | -------------------------------------------------------------------------------- /plan/ical/urls.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.common.utils import url_helper 4 | from plan.ical import views 5 | 6 | urlpatterns = [ 7 | url_helper( 8 | r"^{year}/{semester}/{slug}/ical/(?:{ical}/)?$", 9 | views.ical, 10 | name="schedule-ical", 11 | ), 12 | ] 13 | -------------------------------------------------------------------------------- /plan/locales/nb/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/locales/nb/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /plan/pdf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/pdf/__init__.py -------------------------------------------------------------------------------- /plan/pdf/models.py: -------------------------------------------------------------------------------- 1 | # Placeholder file since django apps must contain models.py 2 | -------------------------------------------------------------------------------- /plan/pdf/tests.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.common.tests import BaseTestCase 4 | 5 | 6 | class EmptyViewTestCase(BaseTestCase): 7 | def test_pdf(self): 8 | args = self.default_args 9 | 10 | pdf_args = [None, "A4", "A5", "A6", "A9", "A7"] 11 | 12 | for size in pdf_args: 13 | if size: 14 | url = self.url("schedule-pdf", *(args + [size])) 15 | else: 16 | url = self.url("schedule-pdf", *args) 17 | 18 | response = self.client.get(url) 19 | if size == "A9": 20 | self.assertEqual(response.status_code, 404) 21 | continue 22 | else: 23 | self.assertEqual(response.status_code, 200) 24 | 25 | response = self.client.get(url) 26 | self.assertEqual(response.status_code, 200) 27 | 28 | 29 | class ViewTestCase(EmptyViewTestCase): 30 | fixtures = ["test_data.json", "test_user.json"] 31 | -------------------------------------------------------------------------------- /plan/pdf/urls.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.common.utils import url_helper 4 | from plan.pdf import views 5 | 6 | urlpatterns = [ 7 | url_helper( 8 | r"^{year}/{semester}/{slug}/pdf/(?:{size}/)?(?:{week}/)?$", 9 | views.pdf, 10 | name="schedule-pdf", 11 | ), 12 | ] 13 | -------------------------------------------------------------------------------- /plan/scrape/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/scrape/__init__.py -------------------------------------------------------------------------------- /plan/scrape/fetch.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import collections 4 | import json as jsonlib 5 | import logging 6 | import time 7 | import urllib.parse 8 | import warnings 9 | 10 | import lxml.etree 11 | import lxml.html 12 | import requests 13 | from django.core import cache 14 | from django.core.cache import CacheKeyWarning 15 | from django.db import connections 16 | from requests.adapters import HTTPAdapter 17 | from requests.packages.urllib3.exceptions import MaxRetryError 18 | from requests.packages.urllib3.util.retry import Retry 19 | 20 | warnings.simplefilter("ignore", CacheKeyWarning) 21 | 22 | 23 | # Global settings that can be twiddled externally 24 | disable_cache = False 25 | max_per_second = float("inf") 26 | 27 | 28 | scraper_cache = cache.caches["scraper"] 29 | 30 | adapter = HTTPAdapter( 31 | max_retries=Retry( 32 | total=3, 33 | status_forcelist=[429, 502, 503, 504], 34 | method_whitelist=["GET", "POST"], 35 | backoff_factor=1, 36 | ) 37 | ) 38 | 39 | session = requests.Session() 40 | session.mount("https://", adapter) 41 | session.mount("http://", adapter) 42 | 43 | 44 | class rate_limit: 45 | previous = 0 46 | 47 | def __init__(self, max_per_second): 48 | self.interval = 1 / float(max_per_second) 49 | 50 | def __enter__(self): 51 | delay = self.interval - (time.time() - rate_limit.previous) 52 | if delay > 0: 53 | logging.debug("Rate limiter applied for %.3f seconds", delay) 54 | time.sleep(delay) 55 | 56 | def __exit__(self, type, value, traceback): 57 | rate_limit.previous = time.time() 58 | 59 | 60 | def sql(db, query, params=None): 61 | cursor = connections[db].cursor() 62 | cursor.execute(query, params or []) 63 | fields = [col[0] for col in cursor.description] 64 | row = collections.namedtuple("row", fields) 65 | for values in cursor.fetchall(): 66 | yield row(*values) 67 | 68 | 69 | # TODO: Store response code and content in cache? 70 | def _fetch(req, key, msg, cache, verbose): 71 | sentinel = object() 72 | result = scraper_cache.get(key, default=sentinel) 73 | 74 | if result is sentinel or not cache or disable_cache: 75 | result = None 76 | try: 77 | prepped = session.prepare_request(req) 78 | with rate_limit(max_per_second): 79 | response = session.send(prepped, timeout=30) 80 | except MaxRetryError: 81 | scraper_cache.set(key, None, timeout=60 * 30) 82 | else: 83 | result = response.text 84 | if response.status_code == 200 and result: 85 | scraper_cache.set(key, result) 86 | elif response.status_code == 500: 87 | scraper_cache.set(key, None, timeout=60 * 30) 88 | else: 89 | msg = "Cached hit: %s" % key 90 | 91 | logging.log(logging.INFO if verbose else logging.DEBUG, msg) 92 | return result 93 | 94 | 95 | def get(url, cache=True, verbose=False): 96 | return _fetch( 97 | requests.Request("GET", url), "get||%s" % (url), "GET: %s" % url, cache, verbose 98 | ) 99 | 100 | 101 | def post(url, data, cache=True, verbose=False): 102 | key = f"post||{url}||{urllib.parse.urlencode(data)}" 103 | msg = f"POST: {url} Data: {data}" 104 | return _fetch(requests.Request("POST", url, data=data), key, msg, cache, verbose) 105 | 106 | 107 | def plain(url, query=None, data=None, verbose=False, cache=True): 108 | if query: 109 | url += "?" + urllib.parse.urlencode(query) 110 | 111 | try: 112 | if data is not None: 113 | return post(url, data, cache=cache, verbose=verbose) 114 | return get(url, cache=cache, verbose=verbose) 115 | except OSError as e: 116 | logging.error("Loading %s as plain failed: %s", url, e) 117 | 118 | 119 | def html(*args, **kwargs): 120 | data = plain(*args, **kwargs) 121 | root = None 122 | if data: 123 | root = lxml.html.fromstring(data) 124 | return root 125 | 126 | 127 | def json(url, *args, **kwargs): 128 | data = plain(url, *args, **kwargs) 129 | if not data: 130 | logging.error("Loading %s as json falied: empty repsonse", url) 131 | return {} 132 | try: 133 | return jsonlib.loads(data) 134 | except ValueError as e: 135 | logging.error("Loading %s as json falied: %s", url, e) 136 | return {} 137 | 138 | 139 | def xml(*args, **kwargs): 140 | data = plain(*args, **kwargs) 141 | if data: 142 | return lxml.etree.fromstring(data) 143 | return None 144 | -------------------------------------------------------------------------------- /plan/scrape/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/scrape/management/__init__.py -------------------------------------------------------------------------------- /plan/scrape/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/scrape/management/commands/__init__.py -------------------------------------------------------------------------------- /plan/scrape/management/commands/prefetch.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import importlib 4 | import logging 5 | 6 | from django.conf import settings 7 | from django.core.management import base as management 8 | 9 | from plan.common.models import Semester 10 | from plan.scrape import fetch 11 | 12 | DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" 13 | CONSOLE_LOG_FORMAT = "[%(asctime)s %(levelname)s] %(message)s" 14 | LOG_LEVELS = {0: logging.ERROR, 1: logging.WARNING, 2: logging.INFO, 3: logging.DEBUG} 15 | 16 | 17 | class Command(management.BaseCommand): 18 | help = "Prefetch data from external sources" 19 | 20 | def add_arguments(self, parser): 21 | super().add_arguments(parser) 22 | 23 | # TODO: Get rid of need for this in load_semester? 24 | ( 25 | parser.add_argument( 26 | "-c", 27 | "--create", 28 | action="store_true", 29 | dest="create", 30 | help="create missing semester, default: false", 31 | ), 32 | ) 33 | 34 | parser.add_argument( 35 | "-y", "--year", action="store", dest="year", type=int, help="year to scrape" 36 | ) 37 | parser.add_argument( 38 | "-t", 39 | "--type", 40 | action="store", 41 | dest="type", 42 | choices=list(dict(Semester.SEMESTER_TYPES).keys()), 43 | help="term to scrape", 44 | ) 45 | parser.add_argument( 46 | "--pdb", 47 | action="store_true", 48 | dest="pdb", 49 | help="use pdb.pm() when we hit and exception", 50 | ) 51 | parser.add_argument( 52 | "--prefix", 53 | action="store", 54 | dest="prefix", 55 | help="course code prefix to limit scrape to", 56 | ) 57 | parser.add_argument( 58 | "--disable_cache", action="store_true", dest="disable_cache" 59 | ) 60 | parser.add_argument( 61 | "--max_per_second", 62 | action="store", 63 | default=5, 64 | dest="max_per_second", 65 | type=float, 66 | ) 67 | 68 | def handle(self, **options): 69 | logging.basicConfig( 70 | format=CONSOLE_LOG_FORMAT, 71 | datefmt=DATE_TIME_FORMAT, 72 | level=LOG_LEVELS[options["verbosity"]], 73 | ) 74 | 75 | fetch.disable_cache = options["disable_cache"] 76 | fetch.max_per_second = options["max_per_second"] or float("inf") 77 | 78 | try: 79 | semester = self.load_semester(options) 80 | for scraper in self.load_scrapers(): 81 | scraper(semester, options["prefix"]).prefetch() 82 | except: 83 | if not options["pdb"]: 84 | raise 85 | 86 | import pdb 87 | import traceback 88 | 89 | traceback.print_exc() 90 | pdb.post_mortem() 91 | 92 | def load_semester(self, options): 93 | year = options["year"] 94 | type = options["type"] 95 | 96 | if not year or not type: 97 | raise management.CommandError("Semester year and/or type is missing.") 98 | 99 | try: 100 | return Semester.objects.get(year=year, type=type) 101 | except Semester.DoesNotExist: 102 | if not options["create"]: 103 | raise 104 | return Semester.objects.create(year=year, type=type) 105 | 106 | def load_scrapers(self): 107 | # TODO: Some of the scrapers depend on courses being loaded, handle this somehow? 108 | scrapers = [] 109 | for scraper in settings.TIMETABLE_SCRAPERS_PREFETCH: 110 | try: 111 | module, cls = scraper.rsplit(".", 1) 112 | scrapers.append(getattr(importlib.import_module(module), cls)) 113 | except ImportError as e: 114 | raise management.CommandError(f"Couldn't import {module}: {e}") 115 | except AttributeError: 116 | raise management.CommandError(f"Scraper {cls} not found in {module}") 117 | return scrapers 118 | -------------------------------------------------------------------------------- /plan/scrape/management/commands/scrape.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import importlib 4 | import logging 5 | 6 | from django.conf import settings 7 | from django.core.management import base as management 8 | from django.db import transaction 9 | 10 | from plan.common.models import Semester 11 | from plan.scrape import fetch, utils 12 | 13 | DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" 14 | CONSOLE_LOG_FORMAT = "[%(asctime)s %(levelname)s] %(message)s" 15 | LOG_LEVELS = {0: logging.ERROR, 1: logging.WARNING, 2: logging.INFO, 3: logging.DEBUG} 16 | 17 | 18 | class Command(management.LabelCommand): 19 | help = ( 20 | "Load data from external sources using specified scraper.\n\n" 21 | "Available scrapers are:\n %s" 22 | % "\n ".join(sorted(settings.TIMETABLE_SCRAPERS)) 23 | ) 24 | 25 | def add_arguments(self, parser): 26 | super().add_arguments(parser) 27 | 28 | parser.add_argument( 29 | "-y", "--year", action="store", dest="year", type=int, help="year to scrape" 30 | ) 31 | parser.add_argument( 32 | "-t", 33 | "--type", 34 | action="store", 35 | dest="type", 36 | choices=list(dict(Semester.SEMESTER_TYPES).keys()), 37 | help="term to scrape", 38 | ) 39 | ( 40 | parser.add_argument( 41 | "-c", 42 | "--create", 43 | action="store_true", 44 | dest="create", 45 | help="create missing semester, default: false", 46 | ), 47 | ) 48 | parser.add_argument("-n", "--dry-run", action="store_true", dest="dry_run") 49 | parser.add_argument( 50 | "--pdb", 51 | action="store_true", 52 | dest="pdb", 53 | help="use pdb.pm() when we hit and exception", 54 | ) 55 | parser.add_argument( 56 | "--prefix", 57 | action="store", 58 | dest="prefix", 59 | help="course code prefix to limit scrape to", 60 | ) 61 | parser.add_argument( 62 | "--disable_cache", action="store_true", dest="disable_cache" 63 | ) 64 | parser.add_argument( 65 | "--max_per_second", 66 | action="store", 67 | default=5, 68 | dest="max_per_second", 69 | type=float, 70 | ) 71 | 72 | @transaction.atomic 73 | def handle_label(self, label, **options): 74 | logging.basicConfig( 75 | format=CONSOLE_LOG_FORMAT, 76 | datefmt=DATE_TIME_FORMAT, 77 | level=LOG_LEVELS[options["verbosity"]], 78 | ) 79 | 80 | fetch.disable_cache = options["disable_cache"] 81 | fetch.max_per_second = options["max_per_second"] or float("inf") 82 | 83 | sid = transaction.savepoint() 84 | 85 | try: 86 | semester = self.load_semester(options) 87 | scraper = self.load_scraper(label)(semester, options["prefix"]) 88 | 89 | needs_commit = scraper.run() 90 | 91 | if not needs_commit or options["dry_run"]: 92 | transaction.savepoint_rollback(sid) 93 | print("No changes, rolled back.") 94 | elif utils.prompt("Commit changes?"): 95 | transaction.savepoint_commit(sid) 96 | print("Commiting changes.") 97 | else: 98 | transaction.savepoint_rollback(sid) 99 | print("Rolled back changes.") 100 | except (SystemExit, KeyboardInterrupt): 101 | transaction.savepoint_rollback(sid) 102 | print("Rolled back changes due to exit.") 103 | except: 104 | try: 105 | if not options["pdb"]: 106 | raise 107 | 108 | import pdb 109 | import traceback 110 | 111 | traceback.print_exc() 112 | pdb.post_mortem() 113 | finally: 114 | # Ensure that we also rollback after pdb sessions. 115 | transaction.savepoint_rollback(sid) 116 | print("Rolled back changes due to unhandeled exception.") 117 | 118 | def load_semester(self, options): 119 | year = options["year"] 120 | type = options["type"] 121 | 122 | if not year or not type: 123 | raise management.CommandError("Semester year and/or type is missing.") 124 | 125 | try: 126 | return Semester.objects.get(year=year, type=type) 127 | except Semester.DoesNotExist: 128 | if not options["create"]: 129 | raise 130 | return Semester.objects.create(year=year, type=type) 131 | 132 | def load_scraper(self, type): 133 | try: 134 | module, cls = settings.TIMETABLE_SCRAPERS.get(type, type).rsplit(".", 1) 135 | return getattr(importlib.import_module(module), cls) 136 | except ImportError as e: 137 | raise management.CommandError(f"Couldn't import {module}: {e}") 138 | except AttributeError: 139 | raise management.CommandError(f"Scraper {cls} not found in {module}") 140 | -------------------------------------------------------------------------------- /plan/scrape/ntnu/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import re 4 | 5 | from plan.common.models import Semester 6 | 7 | SEMESTER_MAPPING = {Semester.SPRING: "v", Semester.FALL: "h"} 8 | 9 | CODE_RE = re.compile(r"^[^0-9]+[0-9]+$") 10 | COURSE_RE = re.compile(r"^([^0-9]+[0-9]+)-(\d+)$") 11 | 12 | 13 | def prefix(semester, template="{letter}{year}"): 14 | year = str(semester.year)[-2:] 15 | letter = SEMESTER_MAPPING[semester.type] 16 | return template.format(letter=letter, year=year) 17 | 18 | 19 | def valid_course_code(code): 20 | if not code: 21 | return False 22 | return bool(CODE_RE.match(code)) 23 | 24 | 25 | def valid_course_version(version): 26 | return str(version).isdigit() 27 | 28 | 29 | def parse_course(raw): 30 | if not raw: 31 | return None, None 32 | m = COURSE_RE.match(raw) 33 | if m: 34 | return m.groups() 35 | return None, None 36 | -------------------------------------------------------------------------------- /plan/scrape/ntnu/akademika.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import tqdm 4 | 5 | from plan.scrape import base, fetch 6 | 7 | 8 | def fetch_syllabus(code): 9 | query = {"curriculum": code.encode("utf-8")} 10 | root = fetch.html("https://www.akademika.no/curriculum/search", query=query) 11 | for e in root.cssselect(".curriculum-search-result[data-id]"): 12 | if not e.xpath('.//a[contains(text(),"NTNU")]'): 13 | continue 14 | url = "https://www.akademika.no/ajax/curriculum/" + e.attrib["data-id"] 15 | for _, _, href, _ in fetch.html(url, verbose=True).iterlinks(): 16 | if href.startswith("/pensum/%s-" % code.lower()): 17 | return "https://www.akademika.no" + href 18 | return "" 19 | 20 | 21 | class Syllabus(base.SyllabusScraper): 22 | def scrape(self): 23 | for c in tqdm.tqdm(self.queryset(), unit="courses"): 24 | yield {"code": c.code, "syllabus": fetch_syllabus(c.code)} 25 | -------------------------------------------------------------------------------- /plan/scrape/ntnu/maze.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import re 4 | 5 | from plan.scrape import base, fetch 6 | 7 | 8 | def normalize(identifier): 9 | return re.sub(r"[.-]", "", identifier).upper() 10 | 11 | 12 | def fetch_pois(tag): 13 | campuses = fetch.json("http://use.mazemap.com/api/campuscollections/?tag=%s" % tag) 14 | base_url = "http://api.mazemap.com/api/pois/?campusid=%s" 15 | 16 | pois = {} 17 | for campus in campuses["children"]: 18 | data = fetch.json(base_url % campus["campusId"]) 19 | for p in data.get("pois", []): 20 | if p["identifier"] and not p["deleted"]: 21 | pois[normalize(p["identifier"])] = p 22 | return pois 23 | 24 | 25 | class Rooms(base.RoomScraper): 26 | def scrape(self): 27 | pois = fetch_pois("ntnu-trondheim") 28 | base_url = "http://use.mazemap.com/?campusid=%s&desttype=identifier&dest=%s" 29 | 30 | for room in self.queryset().filter(code__isnull=False): 31 | poi = pois.get(normalize(room.code)) 32 | 33 | if not poi: 34 | continue 35 | 36 | yield { 37 | "code": room.code, 38 | "name": room.name, 39 | "url": base_url % (poi["campusId"], poi["identifier"]), 40 | } 41 | -------------------------------------------------------------------------------- /plan/scrape/ntnu/tp.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import json 4 | import re 5 | 6 | from plan.common.models import Semester 7 | from plan.scrape import base, fetch, utils 8 | 9 | _LOCATIONS = { 10 | "GLOSHAUGEN": "Trondheim", 11 | "OLAVSKVART": "Trondheim", 12 | "ALESUND": "Ålesund", 13 | "DRAGVOLL": "Trondheim", 14 | "KALVSKINNE": "Trondheim", 15 | "OYA": "Trondheim", 16 | "TUNGA": "Trondheim", 17 | "GJOVIK": "Gjøvik", 18 | "TYHOLT": "Trondheim", 19 | "LERKVALG": "Trondheim", 20 | "MOHOLT": "Trondheim", 21 | "TRONDHEIM": "Trondheim", 22 | } 23 | 24 | 25 | class Courses(base.CourseScraper): 26 | def scrape(self): 27 | year = self.semester.year 28 | if self.semester.type == Semester.SPRING: 29 | year -= 1 30 | 31 | for c in fetch_courses(self.semester): 32 | # TODO: Handle mapping to right semester, e.g. AAR4400 has two terms 33 | # TODO: Handle classes without a campus... 34 | # TODO: Don't hardcode version? 35 | # TODO: Filter to active courses for this semester? 36 | if c["nofterms"] == 1 and c["campusid"] is not None: 37 | yield { 38 | "code": c["id"], 39 | "name": c["name"], 40 | "version": 1, 41 | "url": "https://www.ntnu.no/studier/emner/%s/%s" % (c["id"], year), 42 | "locations": [_LOCATIONS[c["campusid"]]], 43 | } 44 | 45 | 46 | class Lectures(base.LectureScraper): 47 | def scrape(self): 48 | for c in self.course_queryset(): 49 | result = fetch_course_lectures(self.semester, c) 50 | 51 | if "data" not in result or not result["data"]: 52 | continue 53 | 54 | for methods in result["data"].values(): 55 | for method in methods: 56 | for sequence in method["eventsequences"]: 57 | current = None 58 | 59 | for e in sequence["events"]: 60 | tmp = { 61 | "day": utils.parse_date(e["dtstart"]).weekday(), 62 | "start": utils.parse_time(e["dtstart"]), 63 | "end": utils.parse_time(e["dtend"]), 64 | "rooms": [ 65 | (r["id"], r["roomname"], None) 66 | for r in e.get("room", []) 67 | ], 68 | "groups": process_groups(e.get("studentgroups", [])), 69 | } 70 | 71 | if not current: 72 | current = { 73 | "course": c, 74 | "type": method.get( 75 | "teaching-method-name", "teaching-method" 76 | ), 77 | "weeks": [], 78 | "lecturers": [], 79 | } 80 | current.update(tmp) 81 | 82 | for key in tmp: 83 | if current[key] != tmp[key]: 84 | logging.warning( 85 | "Mismatch %s: %s", self.display(obj), key 86 | ) 87 | yield current 88 | current = None 89 | break 90 | else: 91 | current["weeks"].append(e["weeknr"]) 92 | 93 | if current: 94 | yield current 95 | 96 | 97 | def fetch_courses(semester): 98 | query = {"sem": convert_semester(semester)} 99 | resp = fetch.plain("https://tp.uio.no/ntnu/timeplan/emner.php", query) 100 | return json.loads(re.search(r"var courses = (.+);", resp).group(1)) 101 | 102 | 103 | def fetch_course_lectures(semester, course): 104 | url = "https://tp.uio.no/ntnu/ws/1.4/" 105 | query = {"sem": convert_semester(semester), "id": course.code.encode("utf-8")} 106 | result = fetch.json(url, query=query) 107 | 108 | if not result: 109 | query["termnr"] = 1 110 | result = fetch.json(url, query=query) 111 | 112 | return result 113 | 114 | 115 | def convert_semester(semester): 116 | if semester.type == Semester.FALL: 117 | return "%sh" % str(semester.year)[-2:] 118 | else: 119 | return "%sv" % str(semester.year)[-2:] 120 | 121 | 122 | def process_groups(values): 123 | groups = [] 124 | for value in values: 125 | match = re.match(r"^([A-ZÆØÅ]+)", value, re.U) 126 | if match: 127 | groups.append(match.group(1)) 128 | return groups 129 | -------------------------------------------------------------------------------- /plan/scrape/ntnu/web.sample: -------------------------------------------------------------------------------- 1 | Sample output showing the relevant parts of the web page structures. 2 | 3 | $ wget http://www.ntnu.no/studieinformasjon/timeplan/h12/?bokst=A 4 | 5 |
6 |
7 | 8 | 9 | 12 | 29 | 30 |
10 | 11 | 13 |

Timeplaner for høsten 2012

14 |

15 | Emnekode som begynner med..
16 | 17 |

18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
AAR4203-1Form og farge, grunnkurs 1A
AAR4220-1Fysisk oversiktsplanlegging
28 |
31 |
32 |
33 | 34 | 35 | $ wget http://www.ntnu.no/studieinformasjon/timeplan/h12/?emnekode=TDT4120-1 36 | 37 |
38 |
39 | 40 | 41 | 44 | 97 | 98 |
42 | 43 | 45 |

Høsten 2012
Timeplan for:

46 |

TDT4120 - Algoritmer og datastrukturer

47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 63 | 66 | 67 | 70 | 71 | 72 | 75 | 76 | 77 | 80 | 83 | 84 | 87 | 88 | 89 | 94 | 95 |
TidStedLærerPlanlagt for
56 |

Forlesning/Øving

57 |
61 | Tirsdag 16:15 - 19:00
Uke: 34-47

62 |
64 | R1  65 |   68 | BIT
BMAT
MIBYGG
MIEL
MITK
MLREAL
MTBYGG
MTDT
MTEL
MTFYMA
MTING
MTIØT
MTKOM
MTTK
  69 |
73 |

Forelesning

74 |
78 | Onsdag 8:15 - 10:00
Uke: 34-47

79 |
81 | R1  82 |   85 | BIT
BMAT
MIBYGG
MIEL
MITK
MLREAL
MTBYGG
MTDT
MTEL
MTFYMA
MTING
MTIØT
MTKOM
MTTK
  86 |
90 |
91 |

Spørsmål om undervisning og timeplan rettes til instituttet som gir undervisning i emnet.

92 |

Det kan forekomme endringer i timeplan etter publisering. Kontroller timeplanen før semesterstart for å få de siste endringene.

93 |
96 |
99 |
100 |
101 | 102 | -------------------------------------------------------------------------------- /plan/scrape/tests.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.common.tests import BaseTestCase 4 | 5 | 6 | class DBTestCase(BaseTestCase): 7 | pass 8 | 9 | 10 | class StudwebTestCase(BaseTestCase): 11 | pass 12 | 13 | 14 | class ManagmentTestCase(BaseTestCase): 15 | pass 16 | -------------------------------------------------------------------------------- /plan/scrape/utils.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import decimal 4 | import re 5 | import sys 6 | 7 | import dateutil.parser 8 | from django.conf import settings 9 | from django.core.exceptions import ValidationError 10 | from django.core.validators import URLValidator 11 | from django.utils import dates, translation 12 | 13 | # Build lookup table with weekdays in all installed languages. 14 | WEEKDAYS = {} 15 | for lang, name in settings.LANGUAGES: 16 | with translation.override(lang): 17 | for i in range(5): 18 | day = dates.WEEKDAYS[i].lower() 19 | assert WEEKDAYS.get(day, i) == i, "Found conflicting day names." 20 | WEEKDAYS[day] = i 21 | 22 | 23 | def columnify(objects, columns=3): 24 | objects = list(map(str, objects)) 25 | width = max(list(map(len, objects))) 26 | border = str("+-" + "-+-".join(["-" * width] * columns) + "-+") 27 | template = str("| " + " | ".join(["{:%d}" % width] * columns) + " |") 28 | pad_list = lambda i: i + [""] * (columns - len(i)) 29 | lines = [] 30 | 31 | while objects: 32 | lines.append(template.format(*pad_list(objects[:columns]))) 33 | objects = objects[columns:] 34 | 35 | return "\n".join([border] + lines + [border]) 36 | 37 | 38 | def prompt(message): 39 | try: 40 | return input("%s [y/N] " % message).lower() == "y" 41 | except (KeyboardInterrupt, EOFError): 42 | sys.exit(1) 43 | 44 | 45 | def compare(old, new, key=str): 46 | if isinstance(old, str) and isinstance(new, str): 47 | if new.strip() == old.strip(): 48 | return "" 49 | 50 | if isinstance(old, set) and isinstance(new, set): 51 | added = set() 52 | same = set() 53 | removed = set() 54 | 55 | for i in sorted(old | new, key=key): 56 | if i in old and i in new: 57 | same.add(" %s" % i) 58 | elif i in new: 59 | added.add("+%s" % i) 60 | else: 61 | removed.add("-%s" % i) 62 | 63 | return ", ".join(sorted(added) + sorted(removed) + sorted(same)) 64 | 65 | if old == "": 66 | old = "" 67 | if old is None: 68 | old = "" 69 | 70 | if new == "": 71 | new = "" 72 | if new is None: 73 | new = "" 74 | 75 | return f"{old} --> {new}" 76 | 77 | 78 | def clean_string(raw_text): 79 | if not raw_text: 80 | return raw_text 81 | text = raw_text.strip() 82 | if text and text[0] in ('"', "'") and text[0] == text[-1]: 83 | text = text[1:-1].strip() 84 | return text 85 | 86 | 87 | def clean_decimal(raw_number): 88 | if raw_number is None: 89 | return None 90 | return decimal.Decimal(raw_number) 91 | 92 | 93 | def clean_list(items, clean): 94 | return list(filter(bool, list(map(clean, items)))) 95 | 96 | 97 | def valid_url(url): 98 | try: 99 | validator = URLValidator(schemes=("http", "https")) 100 | validator(url) 101 | return True 102 | except ValidationError: 103 | return False 104 | 105 | 106 | def split(value, sep): 107 | return [i.strip() for i in re.split(sep, value) if i.strip()] 108 | 109 | 110 | def parse_day_of_week(value): 111 | """Convert human readable weekday into number. 112 | 113 | Monday=0, ... Saturday and Sunday do not exist. 114 | """ 115 | return WEEKDAYS.get(value.lower(), None) 116 | 117 | 118 | def parse_time(value): 119 | """Convert a textual time to a datetime.time instance.""" 120 | if not value or not value.strip(): 121 | return None 122 | return dateutil.parser.parse(value.strip()).time() 123 | 124 | 125 | def parse_date(value): 126 | """Convert a textual date to a datetime.date instance.""" 127 | if not value or not value.strip(): 128 | return None 129 | return dateutil.parser.parse(value.strip()).date() 130 | 131 | 132 | def parse_weeks(value, sep=r",? "): 133 | """Expand a list of weeks written in shortform.""" 134 | weeks = [] 135 | for v in re.split(sep, value): 136 | if "-" in v: 137 | start, end = v.split("-") 138 | else: 139 | start = end = v 140 | try: 141 | weeks.extend(list(range(int(start), int(end) + 1))) 142 | except ValueError: 143 | pass 144 | return sorted(set(weeks)) 145 | -------------------------------------------------------------------------------- /plan/settings/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.settings.base import * 4 | 5 | try: 6 | from plan.settings.local import * 7 | except ImportError: 8 | pass 9 | -------------------------------------------------------------------------------- /plan/settings/external.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import os 4 | 5 | from plan.settings.base import * 6 | 7 | # Use this settings module when you want to keep your settings in a central 8 | # place like /etc and run your code from a virtualenv etc. 9 | # 10 | # DJANGO_SETTINGS_MODULE=plan.settings.external EXTERNAL_SETTINGS_FILE=/path/to/settings.py ... 11 | 12 | if "EXTERNAL_SETTINGS_FILE" in os.environ: 13 | with open(os.environ["EXTERNAL_SETTINGS_FILE"], "rb") as f: 14 | exec(compile(f.read(), os.environ["EXTERNAL_SETTINGS_FILE"], "exec")) 15 | -------------------------------------------------------------------------------- /plan/settings/local.py-template: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | TEMPLATE_DEBUG = DEBUG 3 | 4 | ADMINS = ( 5 | ('', ''), 6 | ) 7 | MANAGERS = ADMINS 8 | 9 | # Make this unique, and don't share it with anybody. 10 | SECRET_KEY = '' 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'NAME': '...', 15 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 16 | 'USER': '...', 17 | 'PASSWORD': '...', 18 | 'HOST': '...', 19 | }, 20 | 'ntnu': { 21 | 'NAME': '...', 22 | 'ENGINE': 'django.db.backends.mysql', 23 | 'USER': '...', 24 | 'PASSWORD': '...', 25 | 'HOST': '...'', 26 | } 27 | } 28 | 29 | CACHES = { 30 | 'default': { 31 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 32 | 'KEY_PREFIX': '...', 33 | } 34 | } 35 | 36 | # Web URL where Django should expect to find media files 37 | STATIC_URL = '/timeplan/media/' 38 | -------------------------------------------------------------------------------- /plan/settings/test.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from plan.settings.base import * 4 | 5 | SECRET_KEY = "test" 6 | 7 | COMPRESS_ENABLED = False 8 | 9 | DATABASE_ENGINE = "sqlite3" 10 | 11 | CACHES = { 12 | "default": { 13 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 14 | "KEY_PREFIX": "test", 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /plan/static/css/base.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2011, Yahoo! Inc. All rights reserved. 3 | Code licensed under the BSD License: 4 | http://developer.yahoo.com/yui/license.html 5 | version: 2.9.0 6 | */ 7 | /** 8 | * YUI Base 9 | * @module base 10 | * @namespace yui- 11 | * @requires reset, fonts 12 | */ 13 | 14 | body { 15 | /* For breathing room between content and viewport. */ 16 | margin:10px; 17 | } 18 | 19 | h1 { 20 | /* 18px via YUI Fonts CSS foundation. */ 21 | font-size: 138.5%; 22 | } 23 | 24 | h2 { 25 | /* 16px via YUI Fonts CSS foundation. */ 26 | font-size: 123.1%; 27 | } 28 | 29 | h3 { 30 | /* 14px via YUI Fonts CSS foundation. */ 31 | font-size: 108%; 32 | } 33 | 34 | h1,h2,h3 { 35 | /* Top & bottom margin based on font size. */ 36 | margin: 1em 0; 37 | } 38 | 39 | h1,h2,h3,h4,h5,h6,strong,dt { 40 | /* Bringing boldness back to headers and the strong element. */ 41 | font-weight: bold; 42 | } 43 | optgroup { 44 | font-weight:normal; 45 | } 46 | 47 | abbr,acronym { 48 | /* Indicating to users that more info is available. */ 49 | border-bottom: 1px dotted #000; 50 | cursor: help; 51 | } 52 | 53 | em { 54 | /* Bringing italics back to the em element. */ 55 | font-style: italic; 56 | } 57 | 58 | del { 59 | /* Striking deleted phrases. */ 60 | text-decoration: line-through; 61 | } 62 | 63 | blockquote,ul,ol,dl { 64 | /* Giving blockquotes and lists room to breath. */ 65 | margin: 1em; 66 | } 67 | 68 | ol,ul,dl { 69 | /* Bringing lists on to the page with breathing room. */ 70 | margin-left: 2em; 71 | } 72 | 73 | ol { 74 | /* Giving OL's LIs generated numbers. */ 75 | list-style: decimal outside; 76 | } 77 | 78 | ul { 79 | /* Giving UL's LIs generated disc markers. */ 80 | list-style: disc outside; 81 | } 82 | 83 | dl dd { 84 | /* Giving DD default indent. */ 85 | margin-left: 1em; 86 | } 87 | 88 | th,td { 89 | /* Borders and padding to make the table readable. */ 90 | border: 1px solid #000; 91 | padding: .5em; 92 | } 93 | 94 | th { 95 | /* Distinguishing table headers from data cells. */ 96 | font-weight: bold; 97 | text-align: center; 98 | } 99 | 100 | caption { 101 | /* Coordinated margin to match cell's padding. */ 102 | margin-bottom: .5em; 103 | /* Centered so it doesn't blend in to other content. */ 104 | text-align: center; 105 | } 106 | 107 | sup { 108 | /* to preserve line-height and selector appearance */ 109 | vertical-align: super; 110 | } 111 | 112 | sub { 113 | /* to preserve line-height and selector appearance */ 114 | vertical-align: sub; 115 | } 116 | 117 | p, 118 | fieldset, 119 | table, 120 | pre { 121 | /* So things don't run into each other. */ 122 | margin-bottom: 1em; 123 | } 124 | /* Opera requires 1px of padding to render with contemporary native chrome */ 125 | button, 126 | input[type="checkbox"], 127 | input[type="radio"], 128 | input[type="reset"], 129 | input[type="submit"] { 130 | padding:1px; 131 | } 132 | 133 | /* make IE scale images properly */ 134 | /* see http://code.flickr.com/blog/2008/11/12/on-ui-quality-the-little-things-client-side-image-resizing/ */ 135 | img { 136 | -ms-interpolation-mode:bicubic; 137 | } 138 | -------------------------------------------------------------------------------- /plan/static/css/fonts.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2011, Yahoo! Inc. All rights reserved. 3 | Code licensed under the BSD License: 4 | http://developer.yahoo.com/yui/license.html 5 | version: 2.9.0 6 | */ 7 | /** 8 | * YUI Fonts 9 | * @module fonts 10 | * @namespace yui- 11 | * @requires 12 | */ 13 | 14 | /** 15 | * Percents could work for IE, but for backCompat purposes, we are using keywords. 16 | * x-small is for IE6/7 quirks mode. 17 | */ 18 | body { 19 | font:13px/1.231 arial,helvetica,clean,sans-serif; 20 | /* for IE6/7 */ 21 | *font-size:small; 22 | /* for IE Quirks Mode */ 23 | *font:x-small; 24 | } 25 | 26 | /** 27 | * Nudge down to get to 13px equivalent for these form elements 28 | */ 29 | select, 30 | input, 31 | textarea, 32 | button { 33 | font:99% arial,helvetica,clean,sans-serif; 34 | } 35 | 36 | /** 37 | * To help tables remember to inherit 38 | */ 39 | table { 40 | font-size:inherit; 41 | font:100%; 42 | } 43 | 44 | /** 45 | * Bump up IE to get to 13px equivalent for these fixed-width elements 46 | */ 47 | pre, 48 | code, 49 | kbd, 50 | samp, 51 | tt { 52 | font-family:monospace; 53 | *font-size:108%; 54 | line-height:100%; 55 | } 56 | -------------------------------------------------------------------------------- /plan/static/css/grids.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2011, Yahoo! Inc. All rights reserved. 3 | Code licensed under the BSD License: 4 | http://developer.yahoo.com/yui/license.html 5 | version: 2.9.0 6 | */ 7 | /** 8 | * YUI Grids 9 | * @module grids 10 | * @namespace yui- 11 | * @requires reset, fonts 12 | */ 13 | 14 | /** 15 | * Note: Throughout this file, the *property (star-property) filter is used 16 | * to give a value to IE < 8 that other browsers do not see. The _property (underscore-property) 17 | * is only seen by IE < 7, so the combo of *prop and _prop can differentiate between IE6 and IE7. 18 | * 19 | * More information on these filters and related validation errors: 20 | * http://tech.groups.yahoo.com/group/ydn-javascript/message/40059 21 | */ 22 | 23 | /* NOTE: this has been stripped down to just the rules we use. */ 24 | 25 | /** 26 | * Section: General Rules 27 | */ 28 | 29 | body { 30 | text-align: center; 31 | } 32 | 33 | #doc2,.yui-t4 { 34 | margin: auto; 35 | text-align: left; 36 | width: 57.69em; 37 | *width: 56.25em; 38 | } 39 | 40 | /* 950 Centered (doc2) */ 41 | #doc2 { 42 | width: 73.076em; 43 | *width: 71.25em; 44 | } 45 | 46 | /** 47 | * Section: Grids and Nesting Grids 48 | */ 49 | 50 | /* Float units (and sub grids) to the right */ 51 | .yui-g .yui-u { 52 | float: right; 53 | } 54 | 55 | /*Float units (and sub grids) to the left */ 56 | .yui-g div.first { 57 | float: left; 58 | } 59 | 60 | .yui-g .yui-u { 61 | width: 49.1%; 62 | } 63 | 64 | /* @group Clearing */ 65 | #hd:after, 66 | #bd:after, 67 | #ft:after, 68 | .yui-g:after { 69 | content: ""; 70 | display: block; 71 | clear: both; 72 | } 73 | 74 | #hd, 75 | #bd, 76 | #ft, 77 | .yui-g { 78 | zoom: 1; 79 | } 80 | -------------------------------------------------------------------------------- /plan/static/css/icons.css: -------------------------------------------------------------------------------- 1 | [class^="icon-"], 2 | [class*=" icon-"] { 3 | display: inline-block; 4 | width: 14px; 5 | height: 14px; 6 | line-height: 14px; 7 | vertical-align: text-top; 8 | background: url("../gfx/icons/sprite.png") no-repeat; 9 | margin-top: 1px; 10 | } 11 | .icon-arrow-right { background-position: 0px 0px; } 12 | .icon-ban-circle { background-position: 0px -19px; } 13 | .icon-bookmark { background-position: 0px -38px; } 14 | .icon-book { background-position: 0px -57px; } 15 | .icon-briefcase { background-position: 0px -76px; } 16 | .icon-calendar { background-position: -19px 0px; } 17 | .icon-cog { background-position: -19px -19px; } 18 | .icon-cogs { background-position: -19px -38px; } 19 | .icon-download-alt { background-position: -19px -57px; } 20 | .icon-download { background-position: -19px -76px; } 21 | .icon-external-link { background-position: -38px 0px; } 22 | .icon-facebook-sign { background-position: -38px -19px; } 23 | .icon-flag { background-position: -38px -38px; } 24 | .icon-flattr-sign { background-position: -38px -57px; } 25 | .icon-globe { background-position: -38px -76px; } 26 | .icon-google-plus-sign { background-position: -57px 0px; } 27 | .icon-info-sign { background-position: -57px -19px; } 28 | .icon-link { background-position: -57px -38px; } 29 | .icon-list-alt { background-position: -57px -57px; } 30 | .icon-list { background-position: -57px -76px; } 31 | .icon-pencil { background-position: -76px 0px; } 32 | .icon-plus-sign { background-position: -76px -19px; } 33 | .icon-question-sign { background-position: -76px -38px; } 34 | .icon-remove { background-position: -76px -57px; } 35 | .icon-save { background-position: -76px -76px; } 36 | .icon-time { background-position: -95px 0px; } 37 | .icon-twitter-sign { background-position: -95px -19px; } 38 | .icon-warning-sign { background-position: -95px -38px; } 39 | .icon-warning-triangle { background-position: -95px -57px; } 40 | .icon-wrench { background-position: -95px -76px; } 41 | -------------------------------------------------------------------------------- /plan/static/css/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2011, Yahoo! Inc. All rights reserved. 3 | Code licensed under the BSD License: 4 | http://developer.yahoo.com/yui/license.html 5 | version: 2.9.0 6 | */ 7 | /** 8 | * YUI Reset 9 | * @module reset 10 | * @namespace 11 | * @requires 12 | */ 13 | html { 14 | color: #000; 15 | background: #FFF; 16 | } 17 | 18 | body, 19 | div, 20 | dl, 21 | dt, 22 | dd, 23 | ul, 24 | ol, 25 | li, 26 | h1, 27 | h2, 28 | h3, 29 | h4, 30 | h5, 31 | h6, 32 | pre, 33 | code, 34 | form, 35 | fieldset, 36 | legend, 37 | input, 38 | button, 39 | textarea, 40 | select, 41 | p, 42 | blockquote, 43 | th, 44 | td { 45 | margin: 0; 46 | padding: 0; 47 | } 48 | 49 | table { 50 | border-collapse: collapse; 51 | border-spacing: 0; 52 | } 53 | 54 | fieldset, 55 | img { 56 | border: 0; 57 | } 58 | 59 | address, 60 | button, 61 | caption, 62 | cite, 63 | code, 64 | dfn, 65 | em, 66 | input, 67 | optgroup, 68 | option, 69 | select, 70 | strong, 71 | textarea, 72 | th, 73 | var { 74 | font:inherit; 75 | } 76 | 77 | del, 78 | ins { 79 | text-decoration: none; 80 | } 81 | 82 | li { 83 | list-style: none; 84 | } 85 | 86 | caption, 87 | th { 88 | text-align: left; 89 | } 90 | 91 | h1, 92 | h2, 93 | h3, 94 | h4, 95 | h5, 96 | h6 { 97 | font-size: 100%; 98 | font-weight: normal; 99 | } 100 | 101 | q:before, 102 | q:after { 103 | content: ''; 104 | } 105 | 106 | abbr, 107 | acronym { 108 | border: 0; 109 | font-variant: normal; 110 | } 111 | 112 | sup { 113 | vertical-align: baseline; 114 | } 115 | 116 | sub { 117 | vertical-align: baseline; 118 | } 119 | 120 | /*because legend doesn't inherit in IE */ 121 | legend { 122 | color: #000; 123 | } 124 | -------------------------------------------------------------------------------- /plan/static/css/schedule.css: -------------------------------------------------------------------------------- 1 | #next, #previous { 2 | position: absolute; 3 | top: 3.7em; 4 | text-decoration: none; 5 | color: #D3D7CF; 6 | font-size: 300%; 7 | } 8 | #next { 9 | right: -0.55em; 10 | padding-right: 0.05em; 11 | } 12 | #previous { 13 | left: -0.25em; 14 | padding-left: 0.05em; 15 | } 16 | #next:hover { 17 | padding-left: 0.05em; 18 | padding-right: 0; 19 | } 20 | #previous:hover { 21 | padding-left: 0; 22 | padding-right: 0.1em; 23 | } 24 | 25 | /* schedule styling */ 26 | #schedule { 27 | table-layout: fixed; 28 | } 29 | 30 | /* cell contents */ 31 | #schedule .wrapper { 32 | overflow: hidden; 33 | } 34 | #schedule .time .wrapper, #schedule .single .wrapper { 35 | height: 30px; 36 | } 37 | #schedule .course { 38 | float: left; 39 | } 40 | #schedule .room { 41 | font-size: 77%; 42 | float: right; 43 | margin-left: 0.5em; 44 | } 45 | #schedule .type { 46 | clear: both; 47 | font-size: 77%; 48 | } 49 | /* single cells need to be smaller */ 50 | #schedule .single { 51 | font-size: 10px; 52 | } 53 | #schedule .single .type, #schedule .single .room { 54 | font-size: 8px; 55 | } 56 | 57 | #schedule .optional { 58 | color: #555753; 59 | } 60 | 61 | #schedule td { 62 | border-color: #CCC; 63 | padding: 0.1em 0.2em; 64 | vertical-align: top; 65 | height: 2em; 66 | } 67 | #schedule th { 68 | width: 18.5%; 69 | font-size: 77%; 70 | font-weight: normal; 71 | } 72 | 73 | /* time col */ 74 | #schedule .time { 75 | text-align: center; 76 | vertical-align: middle; 77 | font-size: 77%; 78 | width: 7.5%; 79 | white-space: nowrap; 80 | } 81 | #schedule td.time { 82 | border-left: 1px solid #666; 83 | } 84 | #schedule .date { 85 | float: right; 86 | } 87 | 88 | #schedule td.lecture { 89 | border-right: 1px solid #CCC; 90 | } 91 | /* Proper :first-child selectors would be nice, but 92 | * classes from the templates should do the trick */ 93 | #schedule td.last, #schedule td.last.lecture { 94 | border-right: 1px solid #666; 95 | } 96 | #schedule tr.first td { 97 | border-top: 1px solid #666; 98 | } 99 | #schedule td.bottom { 100 | border-bottom: 1px solid #666; 101 | } 102 | -------------------------------------------------------------------------------- /plan/static/css/select_groups.css: -------------------------------------------------------------------------------- 1 | /* Box used for selecting which groups to use for a course */ 2 | #change-groups .groupbox { 3 | margin-bottom: 0.5em; 4 | border: 1px solid #666; 5 | padding: 0.5em 1em; 6 | } 7 | #change-groups ul { 8 | margin: 0; 9 | } 10 | #change-groups li { 11 | list-style: none; 12 | white-space: nowrap; 13 | } 14 | -------------------------------------------------------------------------------- /plan/static/css/start.css: -------------------------------------------------------------------------------- 1 | /* Stats box on front page (needs special casing for graph) */ 2 | #stats { 3 | border-left: 0.2em solid #D3D7CF; 4 | padding: 0.2em 0; 5 | font-size: 93%; 6 | } 7 | #stats div { 8 | margin-bottom: 1px; 9 | padding: 0.1em 0.5em; 10 | -moz-border-radius: 0 2px 2px 0; 11 | -webkit-border-radius: 0 2px 2px 0; 12 | } 13 | #stats span { 14 | float: right; 15 | } 16 | 17 | #totals { 18 | width: auto; 19 | } 20 | #totals td { 21 | border: 0; 22 | padding: 0 1em 0.2em; 23 | } 24 | -------------------------------------------------------------------------------- /plan/static/css/style.css: -------------------------------------------------------------------------------- 1 | /* This file is part of the plan timetable generator, see LICENSE for details. */ 2 | 3 | /* Basic block elements */ 4 | h1,h2,h3 { 5 | font-family: Georgia; 6 | } 7 | h1 { 8 | padding: 0 0.6em; 9 | font-size: 167%; 10 | margin-top: 0; 11 | margin-bottom: -0.25em; 12 | border-bottom: 2px solid #D3D7CF; 13 | } 14 | h2 { 15 | border-bottom: 1px solid #D3D7CF; 16 | padding-left: 0.5em; 17 | margin-left: -0.5em; 18 | margin-top: 0; 19 | } 20 | h3 { 21 | border-bottom: 1px solid #EEEEEC; 22 | padding-left: 0.5em; 23 | margin-left: -0.5em; 24 | margin-top: 0; 25 | } 26 | 27 | hr { 28 | color: #D3D7CF; 29 | background: #D3D7CF; 30 | border: 0; 31 | height: 1px; 32 | } 33 | 34 | /* Tables */ 35 | table { 36 | width: 100%; 37 | } 38 | tr { 39 | vertical-align: top; 40 | } 41 | td, th { 42 | border: 0; 43 | } 44 | th { 45 | text-align: left; 46 | padding: 0 0.5em; 47 | font-size: 93%; 48 | } 49 | td { 50 | border-top: 1px solid #666; 51 | border-bottom: 1px solid #666; 52 | } 53 | 54 | /* Basic inline elements */ 55 | a { 56 | color: #333; 57 | text-decoration: underline; 58 | } 59 | 60 | img { 61 | vertical-align: text-bottom; 62 | } 63 | 64 | /* Form elements */ 65 | button { 66 | font-size: 100%; 67 | font-family: inherit; 68 | padding: 0.09em 0.2em; 69 | vertical-align: bottom; 70 | /* Magic to check buttons the correct size in IE6 */ 71 | width: auto; 72 | overflow:visible; 73 | } 74 | button.link { 75 | background: none; 76 | margin: 0; 77 | padding: 0; 78 | border: none; 79 | cursor: pointer; 80 | text-decoration: underline; 81 | color: #204A87; 82 | display: inline; 83 | } 84 | 85 | input { 86 | padding: 0.15em; 87 | } 88 | textarea[cols], input[size] { 89 | width: auto; /* Don't override sizes set in html */ 90 | } 91 | label { 92 | cursor: pointer; 93 | } 94 | 95 | /* Generic classes */ 96 | .large { 97 | font-size: 116%; /* 15px */ 98 | } 99 | .small { 100 | font-size: 93%; /* 12px */ 101 | } 102 | .tiny { 103 | font-size: 77%; /* 10px */ 104 | } 105 | 106 | .odd { 107 | background: #fafafa; 108 | } 109 | p.right { 110 | text-align: right; 111 | } 112 | 113 | .current { 114 | font-weight: bold; 115 | } 116 | 117 | .hidden { 118 | visibility: hidden; 119 | } 120 | 121 | .clear { 122 | clear: both; 123 | } 124 | 125 | .nowrap { 126 | white-space: nowrap; 127 | } 128 | 129 | /* error classes */ 130 | .errorlist { 131 | color: #A00000; 132 | font-size: 85%; 133 | } 134 | .error { 135 | margin: 0; 136 | } 137 | td.error input, td.error select { 138 | background: #FCC; 139 | } 140 | /* Indicate that things have expired or are excluded */ 141 | .expired td, .hide td, .hide .course, .hide .type, .hide .room { 142 | color: #555753; 143 | } 144 | .delete td, .delete p, .delete .course, .delete .type, .delete .room { 145 | text-decoration: line-through; 146 | } 147 | 148 | /* header/body/footer */ 149 | #hd { 150 | margin-top: 0.5em; 151 | margin-bottom: 1em; 152 | } 153 | #bd { 154 | padding: 0 1em; 155 | margin-bottom: 1em; 156 | position: relative; 157 | } 158 | #ft { 159 | padding: 1em 0.5em 0; 160 | color: #2E3436; 161 | margin-bottom: 1em; 162 | border-top: 1px solid #D3D7CF; 163 | } 164 | 165 | /* Specialcased elements */ 166 | 167 | #help { 168 | border: 1px solid #CCC; 169 | background: #FFC; 170 | padding: 0.5em 0.5em 0; 171 | margin-bottom: 1em; 172 | -moz-border-radius: 5px; 173 | -webkit-border-radius: 5px; 174 | } 175 | #help p { 176 | margin-bottom: 0.5em; 177 | } 178 | 179 | #tips dt { 180 | width: 16px; 181 | float: left; 182 | } 183 | #tips dd { 184 | margin-left: 20px; 185 | line-height: 16px; 186 | margin-bottom: 0.8em; 187 | } 188 | 189 | #attribution { 190 | text-align: center; 191 | } 192 | 193 | @media print { 194 | /* FIXME dont like noprint :( */ 195 | #ft, .noprint { 196 | display: none; 197 | } 198 | table a { 199 | text-decoration: none; 200 | } 201 | } 202 | 203 | /* Language selector */ 204 | #setlang { 205 | float: right; 206 | } 207 | #share { 208 | text-align: center; 209 | padding: 0.3em 0; 210 | } 211 | #share a { 212 | opacity: 0.7; 213 | font-size: 116%; /* 15px */ 214 | text-decoration: none; 215 | margin: 0 3px; 216 | } 217 | #share a:hover { 218 | opacity: 1; 219 | } 220 | 221 | /* Autocomplete */ 222 | .autocomplete-suggestions { 223 | width: auto !important; 224 | max-width: 90%; 225 | border: 1px solid #ccc !important; 226 | } 227 | 228 | /* Switch behavior on "small" screens */ 229 | @media screen and (max-width: 1023px) { 230 | #doc2, .yui-g .yui-u, .yui-g div.first, .yui-g .yui-u { 231 | float: none; 232 | width: auto; 233 | } 234 | .overflow { 235 | overflow-x: auto; 236 | } 237 | #schedule { 238 | width: 73.076em; 239 | *width: 71.25em; 240 | } 241 | #next, #previous { 242 | display: none; 243 | } 244 | } 245 | 246 | 247 | -------------------------------------------------------------------------------- /plan/static/gfx/icons/arrow-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/arrow-right.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/ban-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/ban-circle.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/book.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/bookmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/bookmark.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/briefcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/briefcase.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/calendar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/calendar.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/cog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/cog.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/cogs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/cogs.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/download-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/download-alt.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/download.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/external-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/external-link.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/facebook-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/facebook-sign.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/flag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/flag.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/flattr-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/flattr-sign.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/globe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/globe.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/google-plus-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/google-plus-sign.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/info-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/info-sign.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/link.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/list-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/list-alt.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/list.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/pencil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/pencil.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/plus-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/plus-sign.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/question-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/question-sign.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/remove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/remove.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/save.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/sprite.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/time.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/time.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/twitter-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/twitter-sign.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/warning-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/warning-sign.png -------------------------------------------------------------------------------- /plan/static/gfx/icons/wrench.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/gfx/icons/wrench.png -------------------------------------------------------------------------------- /plan/static/js/advanced.js: -------------------------------------------------------------------------------- 1 | /* This file is part of the plan timetable generator, see LICENSE for details. */ 2 | 3 | // TODO: use http://www.hunlock.com/blogs/Totally_Pwn_CSS_with_Javascript instead 4 | 5 | (function () { 6 | function attach(selector, type, action) { 7 | var func, 8 | inputs = document.querySelectorAll(selector); 9 | for (var i = 0; i < inputs.length; i++) { 10 | func = toggle.bind(inputs[i], "." + type + "-" + inputs[i].value, action); 11 | inputs[i].addEventListener("change", func, false); 12 | func(); 13 | } 14 | } 15 | 16 | function toggle(selector, klass) { 17 | var targets = document.querySelectorAll(selector); 18 | for (var i = 0; i < targets.length; i++) { 19 | if (this.checked) { 20 | targets[i].classList.add(klass); 21 | } else { 22 | targets[i].classList.remove(klass); 23 | } 24 | } 25 | } 26 | 27 | function normalize(value) { 28 | return value.trim().toLowerCase().replace(/\s+/, " "); 29 | } 30 | 31 | function init() { 32 | document.removeEventListener("DOMContentLoaded", arguments.callee, false); 33 | attach('#courses input[name="course_remove"]', "course", "delete"); 34 | attach('#lectures input[name="exclude"]', "lecture", "hide"); 35 | 36 | document 37 | .querySelectorAll("[data-toggle-container]") 38 | .forEach((container) => { 39 | const filter = container.querySelector("[data-filter]"); 40 | const rows = [...container.querySelectorAll("tbody tr")]; 41 | 42 | const keys = [...container.querySelector("thead tr").children].map( 43 | (th, index) => th.dataset.search ?? null, 44 | ); 45 | 46 | const data = rows.map((tr) => 47 | [...tr.children] 48 | .map((child, index) => 49 | keys[index] !== null ? normalize(child.textContent) : "", 50 | ) 51 | .filter((d) => d.length > 0) 52 | .join(" "), 53 | ); 54 | 55 | const update = () => { 56 | const needle = normalize(filter.value) 57 | .split(" ") 58 | .filter((n) => n.length > 0); 59 | 60 | const result = data.map((d, index) => { 61 | return needle.every((n) => d.includes(n)) ? index : null; 62 | }); 63 | 64 | rows.forEach( 65 | (tr, index) => 66 | (tr.hidden = !(needle.length == 0 || result.includes(index))), 67 | ); 68 | }; 69 | 70 | filter.addEventListener("input", update); 71 | filter.addEventListener("keydown", (event) => { 72 | if (event.key === "Escape" || (event.key == "c" && event.ctrlKey)) { 73 | filter.value = ""; 74 | update(); 75 | } 76 | }); 77 | }); 78 | } 79 | 80 | if (document.readyState === "loading") { 81 | document.addEventListener("DOMContentLoaded", init, false); 82 | } else { 83 | init(); 84 | } 85 | })(); 86 | -------------------------------------------------------------------------------- /plan/static/js/autocomplete.js: -------------------------------------------------------------------------------- 1 | /* This file is part of the plan timetable generator, see LICENSE for details. */ 2 | 3 | (function () { 4 | var xhr, 5 | cache = {}; 6 | 7 | function fetch(url, callback) { 8 | try { 9 | xhr.abort(); 10 | } catch (e) {} 11 | xhr = new XMLHttpRequest(); 12 | xhr.onreadystatechange = function () { 13 | if (this.readyState == 4 && this.status == 200) { 14 | callback(JSON.parse(this.responseText)); 15 | } 16 | }; 17 | xhr.open("GET", url, true); 18 | xhr.setRequestHeader("Accept", "application/json"); 19 | xhr.send(); 20 | } 21 | 22 | function source(term, callback) { 23 | term = term 24 | .split(/\s*,\s*/) 25 | .pop() 26 | .replace(/^\s+|\s+/g, "") 27 | .toLowerCase(); 28 | var location = document.getElementById("location"); 29 | var query = 30 | "?q=" + 31 | encodeURIComponent(term) + 32 | "&l=" + 33 | encodeURIComponent(location !== null ? location.value : ""); 34 | 35 | if (cache[query]) { 36 | callback(cache[query]); 37 | } else if (term.length >= 3) { 38 | var url = this.selector.getAttribute("data-autocomplete"); 39 | fetch(url + query, function (data) { 40 | cache[query] = data; 41 | callback(data); 42 | }); 43 | } 44 | } 45 | 46 | function render(item, search) { 47 | var s = document.createElement("div"); 48 | s.className = "autocomplete-suggestion"; 49 | s.setAttribute("data-code", item[0]); 50 | s.setAttribute("data-val", search); 51 | var b = document.createElement("b"); 52 | b.appendChild(document.createTextNode(item[0])); 53 | s.appendChild(b); 54 | s.appendChild(document.createTextNode(": " + item[1])); 55 | return s.outerHTML; 56 | } 57 | 58 | function select(e, term, item) { 59 | var terms = term.split(/\s*,\s*/); 60 | terms[terms.length - 1] = item.getAttribute("data-code"); 61 | this.selector.value = terms.join(", ") + ", "; 62 | e.preventDefault(); 63 | return false; 64 | } 65 | 66 | function init() { 67 | document.removeEventListener("DOMContentLoaded", arguments.callee, false); 68 | new autoComplete({ 69 | selector: document.getElementById("course"), 70 | minChars: 0, 71 | cache: false, 72 | source: source, 73 | renderItem: render, 74 | onSelect: select, 75 | }); 76 | } 77 | 78 | if (document.readyState === "loading") { 79 | document.addEventListener("DOMContentLoaded", init, false); 80 | } else { 81 | init(); 82 | } 83 | })(); 84 | -------------------------------------------------------------------------------- /plan/static/js/calendar.js: -------------------------------------------------------------------------------- 1 | /* This file is part of the plan timetable generator, see LICENSE for details. */ 2 | 3 | (function () { 4 | const zigZagDecode = (i) => (i >> 1) ^ -(i & 1); 5 | 6 | class DeltaDeltaDecoder { 7 | constructor() { 8 | this.prev = null; 9 | this.prev_delta = 0; 10 | } 11 | decode(value) { 12 | if (this.prev === null) { 13 | this.prev = value; 14 | return value; 15 | } 16 | this.prev_delta += value; 17 | this.prev += this.prev_delta; 18 | return this.prev; 19 | } 20 | } 21 | 22 | window.drawCalendar = (container, url) => { 23 | // FIXME: Consider show active dates for new semesters? 24 | fetch(url) 25 | .then((response) => response.text()) 26 | .then((content) => { 27 | const data = []; 28 | 29 | const daysDecoder = new DeltaDeltaDecoder(); 30 | const countsDecoder = new DeltaDeltaDecoder(); 31 | 32 | content.split(",").forEach((v) => { 33 | v = v.split(":"); 34 | v = v.length == 1 ? [0, v[0]] : v; 35 | v = v.map((i) => zigZagDecode(Number(i))); 36 | data.push({ 37 | date: new Date(8.64e7 * daysDecoder.decode(v[0])), 38 | value: countsDecoder.decode(v[1]), 39 | }); 40 | }); 41 | return data; 42 | }) 43 | .then((data) => { 44 | const start = d3.utcDay.offset(d3.min(data, (d) => d.date)); 45 | const end = d3.utcDay.offset(d3.max(data, (d) => d.date)); 46 | 47 | const iso_year = (d) => Number(d3.timeFormat("%G")(d)); 48 | const iso_week = (d) => Number(d3.timeFormat("%V")(d)); 49 | const iso_dow = (d) => Number(d3.timeFormat("%u")(d)); 50 | 51 | function calendar({ 52 | date = Plot.identity, 53 | inset = 0.5, 54 | ...options 55 | } = {}) { 56 | let D; 57 | return { 58 | fy: { 59 | transform: (data) => 60 | (D = Plot.valueof(data, date, Array)).map((d) => iso_year(d)), 61 | }, 62 | x: { 63 | transform: () => D.map((d) => iso_week(d) - 1), 64 | }, 65 | y: { transform: () => D.map((d) => iso_dow(d)) }, 66 | inset, 67 | ...options, 68 | }; 69 | } 70 | 71 | class MonthLine extends Plot.Mark { 72 | static defaults = { stroke: "currentColor", strokeWidth: 1 }; 73 | constructor(data, options = {}) { 74 | const { x, y } = options; 75 | super( 76 | data, 77 | { x: { value: x, scale: "x" }, y: { value: y, scale: "y" } }, 78 | options, 79 | MonthLine.defaults, 80 | ); 81 | } 82 | render(index, { x, y }, { x: X, y: Y }, dimensions) { 83 | const { marginTop, marginBottom, height } = dimensions; 84 | const dx = x.bandwidth(), 85 | dy = y.bandwidth(); 86 | return htl.svg` 89 | `${ 90 | Y[i] > marginTop + dy * 1.5 // is the first day a Monday? 91 | ? `M${X[i] + dx},${marginTop + dy}V${Y[i]}h${-dx}` 92 | : `M${X[i]},${marginTop + dy}` 93 | }V${height - marginBottom}`, 94 | ).join("")}>`; 95 | } 96 | } 97 | 98 | return Plot.plot({ 99 | padding: 0, 100 | width: 920, 101 | height: d3.utcYear.count(start, end) * 100, 102 | //marginLeft: 10, 103 | x: { 104 | axis: null, 105 | domain: d3.range(53), 106 | }, 107 | y: { 108 | axis: "left", 109 | tickFormat: Plot.formatWeekday("en"), 110 | tickSize: 0, 111 | domain: [-1, 1, 2, 3, 4, 5, 6, 7], 112 | ticks: [1, 2, 3, 4, 5, 6, 7], 113 | }, 114 | fy: { 115 | tickFormat: "", 116 | reverse: true, 117 | }, 118 | color: { 119 | // FIXME: Tweak scale 120 | scheme: "Greens", 121 | type: "pow", 122 | exponent: 1 / 5, 123 | }, 124 | marks: [ 125 | Plot.cell( 126 | data, 127 | calendar({ 128 | date: "date", 129 | fill: (d) => d.value, 130 | title: (d) => 131 | `${d3.utcFormat("%Y-%m-%d")(d.date)}\n${d.value} new timetables`, 132 | }), 133 | ), 134 | new MonthLine( 135 | d3.utcMonths(d3.utcMonth(start), end), 136 | calendar({ stroke: "white", strokeWidth: 3 }), 137 | ), 138 | Plot.text( 139 | d3.utcMonths(d3.utcMonth(start), end).map(d3.utcThursday.ceil), 140 | calendar({ 141 | text: d3.utcFormat("%b"), 142 | frameAnchor: "left", 143 | y: -1, 144 | dx: -5, 145 | }), 146 | ), 147 | Plot.text( 148 | // Group by last sunday of the month, i.e. get next month and go one day back, then find sunday. 149 | [ 150 | ...d3.rollup( 151 | data, 152 | (v) => d3.sum(v, (d) => d.value), 153 | (d) => 154 | d3.utcThursday.floor( 155 | d3.utcDay.offset(d3.utcMonth.ceil(d.date), -1), 156 | ), 157 | ), 158 | ].map(([date, value]) => ({ date, value })), 159 | calendar({ 160 | date: "date", 161 | text: (d) => d.value, 162 | frameAnchor: "right", 163 | y: -1, 164 | dx: 5, 165 | }), 166 | ), 167 | //Plot.text( 168 | // d3.utcDays(start, end), 169 | // calendar({ text: d3.utcFormat("%-d") }), 170 | //), 171 | ], 172 | }); 173 | }) 174 | .then((plot) => { 175 | container.appendChild(plot); 176 | }); 177 | }; 178 | })(); 179 | -------------------------------------------------------------------------------- /plan/static/js/lib/auto-complete.css: -------------------------------------------------------------------------------- 1 | .autocomplete-suggestions { 2 | text-align: left; cursor: default; border: 1px solid #ccc; border-top: 0; background: #fff; box-shadow: -1px 1px 3px rgba(0,0,0,.1); 3 | 4 | /* core styles should not be changed */ 5 | position: absolute; display: none; z-index: 9999; max-height: 254px; overflow: hidden; overflow-y: auto; box-sizing: border-box; 6 | } 7 | .autocomplete-suggestion { position: relative; padding: 0 .6em; line-height: 23px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 1.02em; color: #333; } 8 | .autocomplete-suggestion b { font-weight: normal; color: #1f8dd6; } 9 | .autocomplete-suggestion.selected { background: #f0f0f0; } 10 | -------------------------------------------------------------------------------- /plan/static/js/lib/auto-complete.min.js: -------------------------------------------------------------------------------- 1 | // JavaScript autoComplete v1.0.4 2 | // https://github.com/Pixabay/JavaScript-autoComplete 3 | var autoComplete=function(){function e(e){function t(e,t){return e.classList?e.classList.contains(t):new RegExp("\\b"+t+"\\b").test(e.className)}function o(e,t,o){e.attachEvent?e.attachEvent("on"+t,o):e.addEventListener(t,o)}function s(e,t,o){e.detachEvent?e.detachEvent("on"+t,o):e.removeEventListener(t,o)}function n(e,s,n,l){o(l||document,s,function(o){for(var s,l=o.target||o.srcElement;l&&!(s=t(l,e));)l=l.parentElement;s&&n.call(l,o)})}if(document.querySelector){var l={selector:0,source:0,minChars:3,delay:150,offsetLeft:0,offsetTop:1,cache:1,menuClass:"",renderItem:function(e,t){t=t.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&");var o=new RegExp("("+t.split(" ").join("|")+")","gi");return'
'+e.replace(o,"$1")+"
"},onSelect:function(){}};for(var c in e)e.hasOwnProperty(c)&&(l[c]=e[c]);for(var a="object"==typeof l.selector?[l.selector]:document.querySelectorAll(l.selector),u=0;u0?i.sc.scrollTop=n+i.sc.suggestionHeight+s-i.sc.maxHeight:0>n&&(i.sc.scrollTop=n+s)}else i.sc.scrollTop=0},o(window,"resize",i.updateSC),document.body.appendChild(i.sc),n("autocomplete-suggestion","mouseleave",function(){var e=i.sc.querySelector(".autocomplete-suggestion.selected");e&&setTimeout(function(){e.className=e.className.replace("selected","")},20)},i.sc),n("autocomplete-suggestion","mouseover",function(){var e=i.sc.querySelector(".autocomplete-suggestion.selected");e&&(e.className=e.className.replace("selected","")),this.className+=" selected"},i.sc),n("autocomplete-suggestion","mousedown",function(e){if(t(this,"autocomplete-suggestion")){var o=this.getAttribute("data-val");i.value=o,l.onSelect(e,o,this),i.sc.style.display="none"}},i.sc),i.blurHandler=function(){try{var e=document.querySelector(".autocomplete-suggestions:hover")}catch(t){var e=0}e?i!==document.activeElement&&setTimeout(function(){i.focus()},20):(i.last_val=i.value,i.sc.style.display="none",setTimeout(function(){i.sc.style.display="none"},350))},o(i,"blur",i.blurHandler);var r=function(e){var t=i.value;if(i.cache[t]=e,e.length&&t.length>=l.minChars){for(var o="",s=0;st||t>40)&&13!=t&&27!=t){var o=i.value;if(o.length>=l.minChars){if(o!=i.last_val){if(i.last_val=o,clearTimeout(i.timer),l.cache){if(o in i.cache)return void r(i.cache[o]);for(var s=1;s{if(null===e.firstChild)return null;if(e.firstChild===e.lastChild)return e.removeChild(e.firstChild);const t=document.createElement("span");return t.appendChild(e),t})),{fragment:T(t,(e=>e))}),a=Object.assign(T(n,(e=>null===e.firstChild?null:e.firstChild===e.lastChild?e.removeChild(e.firstChild):e)),{fragment:T(n,(e=>{const t=document.createDocumentFragment();for(;e.firstChild;)t.appendChild(e.firstChild);return t}))}),s=60,i=62,o=47,c=45,l=33,f=61,u=10,d=11,p=12,b=13,h=14,k=17,g=22,m=23,w=26,x="http://www.w3.org/2000/svg",C="http://www.w3.org/1999/xlink",y="http://www.w3.org/XML/1998/namespace",v="http://www.w3.org/2000/xmlns/",A=new Map(["attributeName","attributeType","baseFrequency","baseProfile","calcMode","clipPathUnits","diffuseConstant","edgeMode","filterUnits","glyphRef","gradientTransform","gradientUnits","kernelMatrix","kernelUnitLength","keyPoints","keySplines","keyTimes","lengthAdjust","limitingConeAngle","markerHeight","markerUnits","markerWidth","maskContentUnits","maskUnits","numOctaves","pathLength","patternContentUnits","patternTransform","patternUnits","pointsAtX","pointsAtY","pointsAtZ","preserveAlpha","preserveAspectRatio","primitiveUnits","refX","refY","repeatCount","repeatDur","requiredExtensions","requiredFeatures","specularConstant","specularExponent","spreadMethod","startOffset","stdDeviation","stitchTiles","surfaceScale","systemLanguage","tableValues","targetX","targetY","textLength","viewBox","viewTarget","xChannelSelector","yChannelSelector","zoomAndPan"].map((e=>[e.toLowerCase(),e]))),N=new Map([["xlink:actuate",C],["xlink:arcrole",C],["xlink:href",C],["xlink:role",C],["xlink:show",C],["xlink:title",C],["xlink:type",C],["xml:lang",y],["xml:space",y],["xmlns",v],["xmlns:xlink",v]]);function T(e,t){return function({raw:n}){let r,a,x,C,y=1,v="",A=0;for(let e=0,t=arguments.length;e0){const r=arguments[e];switch(y){case w:if(null!=r){const e=`${r}`;if(E(a))v+=e.replace(/[<]/g,L);else{if(new RegExp(`/]`,"i").test(v.slice(-a.length-2)+e))throw new Error("unsafe raw text");v+=e}}break;case 1:null==r||(r instanceof Node||"string"!=typeof r&&r[Symbol.iterator]||/(?:^|>)$/.test(n[e-1])&&/^(?:<|$)/.test(t)?(v+="\x3c!--::"+e+"--\x3e",A|=128):v+=`${r}`.replace(/[<&]/g,L));break;case 9:{let a;if(y=p,/^[\s>]/.test(t)){if(null==r||!1===r){v=v.slice(0,x-n[e-1].length);break}if(!0===r||""==(a=`${r}`)){v+="''";break}if("style"===n[e-1].slice(x,C)&&M(r)||"function"==typeof r){v+="::"+e,A|=1;break}}if(void 0===a&&(a=`${r}`),""===a)throw new Error("unsafe unquoted empty string");v+=a.replace(/^['"]|[\s>&]/g,L);break}case p:v+=`${r}`.replace(/[\s>&]/g,L);break;case d:v+=`${r}`.replace(/['&]/g,L);break;case u:v+=`${r}`.replace(/["&]/g,L);break;case 6:if(M(r)){v+="::"+e+"=''",A|=1;break}throw new Error("invalid binding");case k:break;default:throw new Error("invalid binding")}}for(let e=0,n=t.length;e=0;--r)a=t.insertBefore(n[r],a);else for(const r of n)null!=r&&t.insertBefore(r instanceof Node?r:document.createTextNode(r),e);else t.insertBefore(document.createTextNode(n),e);R.push(e)}}}for(const e of R)e.parentNode.removeChild(e);return t(N)}}function L(e){return`&#${e.charCodeAt(0).toString()};`}function S(e){return 65<=e&&e<=90||97<=e&&e<=122}function U(e){return 9===e||10===e||12===e||32===e||13===e}function M(e){return e&&e.toString===Object.prototype.toString}function $(e){return"script"===e||"style"===e||E(e)}function E(e){return"textarea"===e||"title"===e}function j(e,t,n){return e.slice(t,n).toLowerCase()}function O(e,t,n){e.namespaceURI===x&&(t=t.toLowerCase(),t=A.get(t)||t,N.has(t))?e.setAttributeNS(N.get(t),t,n):e.setAttribute(t,n)}function P(e,t){e.namespaceURI===x&&(t=t.toLowerCase(),t=A.get(t)||t,N.has(t))?e.removeAttributeNS(N.get(t),t):e.removeAttribute(t)}function B(e,t){for(const n in t){const r=t[n];n.startsWith("--")?e.setProperty(n,r):e[n]=r}}e.html=r,e.svg=a,e.version="0.3.1",Object.defineProperty(e,"__esModule",{value:!0})})); 3 | -------------------------------------------------------------------------------- /plan/static/js/navigation.js: -------------------------------------------------------------------------------- 1 | /* This file is part of the plan timetable generator, see LICENSE for details. */ 2 | 3 | document.addEventListener( 4 | "keyup", 5 | function (e) { 6 | var link, 7 | inputs = ["INPUT", "TEXTAREA", "BUTTON", "SELECT"], 8 | scroll = 9 | document.documentElement.scrollWidth > 10 | document.documentElement.clientWidth; 11 | if (inputs.indexOf(event.target.tagName) >= 0) { 12 | return true; 13 | } 14 | if (e.keyCode == 74 || (!scroll && e.keyCode == 37)) { 15 | // j or ← 16 | link = document.getElementById("previous"); 17 | } else if (e.keyCode == 75 || (!scroll && e.keyCode == 39)) { 18 | // k or → 19 | link = document.getElementById("next"); 20 | } 21 | if (link && link.href) { 22 | document.location = link.href; 23 | } 24 | }, 25 | false, 26 | ); 27 | -------------------------------------------------------------------------------- /plan/static/js/toggle.js: -------------------------------------------------------------------------------- 1 | /* This file is part of the plan timetable generator, see LICENSE for details. */ 2 | 3 | (function () { 4 | function init() { 5 | document.removeEventListener("DOMContentLoaded", arguments.callee, false); 6 | 7 | for (var group of document.querySelectorAll("[data-toggle-container]")) { 8 | group.style.display = "block"; 9 | group.querySelectorAll("[data-toggle]").forEach((toggle) => { 10 | toggle.style.cursor = "pointer"; 11 | toggle.addEventListener( 12 | "click", 13 | ((inputs, event) => { 14 | event.preventDefault(); 15 | 16 | inputs.forEach((input) => { 17 | const targetState = event.target.dataset.toggle == "true"; 18 | if (input.checked != targetState && input.offsetParent !== null) { 19 | input.click(); 20 | } 21 | }); 22 | }).bind(null, group.querySelectorAll('input[type="checkbox"]')), 23 | ); 24 | }); 25 | } 26 | } 27 | 28 | if (document.readyState === "loading") { 29 | document.addEventListener("DOMContentLoaded", init, false); 30 | } else { 31 | init(); 32 | } 33 | })(); 34 | -------------------------------------------------------------------------------- /plan/static/map/auditorier_dragvoll.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/map/auditorier_dragvoll.png -------------------------------------------------------------------------------- /plan/static/map/auditorier_gloshaugen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/map/auditorier_gloshaugen.png -------------------------------------------------------------------------------- /plan/static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamcik/plan/433a4bf1b9a28daabd5fd4714259c43736b7ec94/plan/static/screenshot.png -------------------------------------------------------------------------------- /plan/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {{ block.super }} - {% trans "Page not found" %} 7 | {% endblock %} 8 | 9 | {% block hd %} 10 | {{ block.super }} 11 |

{% trans "Page not found" %}

12 | {% endblock %} 13 | 14 | {% block bd %} 15 |
16 |

17 | {% url 'frontpage' as frontpage_url %} 18 | 19 | {% blocktrans with request_path|default:"-" as path %} 20 | {{ path }} could not be found. Try going back or 21 | starting over from the frontpage. 22 | {% endblocktrans %} 23 |

24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /plan/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %} 6 | {{ block.super }} - {% trans "Server error" %} 7 | {% endblock %} 8 | 9 | {% block hd %} 10 | {{ block.super }} 11 |

{% trans "Server error" %}

12 | {% endblock %} 13 | 14 | {% block bd %} 15 |
16 |

17 | 18 | {% trans "An error has occurred, sorry about the inconvenience." %} 19 | {% trans "The server administrator has already been notified of this error." %} 20 |

21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /plan/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% load compress %} 4 | {% load i18n %} 5 | {% load static %} 6 | 7 | {% block title %} 8 | {% trans "Number of timetables" %} 9 | {% endblock %} 10 | 11 | {% block lang %} 12 | {% endblock %} 13 | 14 | {% block hd %} 15 | {{ block.super }} 16 |

17 | {% trans "Number of timetables over time" %} 18 |

19 | {% endblock %} 20 | 21 | {% block extrascript %} 22 | {% compress js %} 23 | 24 | 25 | 26 | 27 | 40 | {% endcompress %} 41 | {% endblock %} 42 | 43 | {% block bd %} 44 |
45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /plan/templates/add_courses.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if advanced or not courses %} 4 |
5 | {% if courses %} 6 |

{% trans "Add courses" %}

7 | {% endif %} 8 |

9 | 10 | {% if locations|length > 1 %} 11 | 12 | 18 | {% endif %} 19 | 20 | 21 |

22 |
23 | {% endif %} 24 | -------------------------------------------------------------------------------- /plan/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | {% block title %}{% endblock %} 7 | {% block script %}{% endblock %} 8 | {% block style %}{% endblock %} 9 | {% block extrastyle %}{% endblock %} 10 | {% block extrahead %}{% endblock %} 11 | 12 | 13 |
14 | {% block hd %}{% endblock %} 15 |
16 |
17 | {% block bd %}{% endblock %} 18 |
19 |
20 | {% block ft %}{% endblock %} 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /plan/templates/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load compress %} 4 | {% load i18n %} 5 | {% load static %} 6 | {% load strip %} 7 | 8 | {% block title %} 9 | {% blocktrans with INSTITUTION as institution %} 10 | Timetable generator for {{ institution }} students 11 | {% endblocktrans %} 12 | {% endblock %} 13 | 14 | {% block style %} 15 | {% compress css %} 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% endcompress %} 23 | {% endblock %} 24 | 25 | {% block extrahead %} 26 | 27 | 28 | 29 | {% endblock %} 30 | 31 | {% block script %} 32 | {% if STATIC_DOMAIN %} 33 | 34 | {% endif %} 35 | {% compress js inline %} 36 | {% if ANALYTICS_CODE %} 37 | 43 | {% endif %} 44 | {% endcompress %} 45 | {% block extrascript %}{% endblock %} 46 | {% if ANALYTICS_CODE %} 47 | 48 | {% endif %} 49 | {% endblock %} 50 | 51 | {% block hd %} 52 | {% include "setlang.html" %} 53 | {% endblock %} 54 | 55 | {% block ft %} 56 |
57 |
58 |

59 | {% blocktrans with INSTITUTION as institution and INSTITUTION_SITE as url %} 60 | All lecture times and course data have been automatically retrieved 61 | from {{ institution }}. This data may not 62 | reflect the actual lecture times due to changes or erroneous imports. 63 | The service is provided as is, please ensure that the data is correct 64 | before relying on it. 65 | {% endblocktrans %} 66 |

67 |
68 |
69 |

70 | {% now "Y" as year %} 71 | {% blocktrans with INSTITUTION as institution and year as year %} 72 | Code and design © 2008-{{ year }} Thomas Adamcik. 73 | This site has no official affiliation with {{ institution }}. 74 | {% endblocktrans %} 75 | {% if ADMINS %} 76 | {{ SITENAME }} 77 | {% trans "is run and hosted by" %} 78 | {% for name, email in ADMINS %} 79 | {{ name }}{% if not forloop.last %},{% else %}.{% endif %} 80 | {% endfor %} 81 | 82 | {% endif %} 83 |

84 |
85 |
86 | 87 |
88 | 89 |

90 | {% blocktrans %} 91 | The source code is freely available under the 92 | Affero General Public License 93 | at {{ SOURCE_URL }}. 94 | {% endblocktrans %} 95 |
96 | 97 | {% trans "Built using:" %} 98 | Python • 99 | Django • 100 | Yahoo! UI Library • 101 | Font Awesome • 102 | ColorBrewer 103 | 104 |

105 | 106 | {% if SHARE_LINKS %} 107 |

108 | {% for icon, name, url in SHARE_LINKS %} 109 | {{ name }} 110 | {% endfor %} 111 |

112 | {% endif %} 113 | {% endblock %} 114 | -------------------------------------------------------------------------------- /plan/templates/compressor/js_file.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /plan/templates/courses.html: -------------------------------------------------------------------------------- 1 | {% load get %} 2 | {% load hostname %} 3 | {% load i18n %} 4 | {% load tabindex %} 5 | 6 |
7 |

{% trans "Courses" %}

8 | {% if courses %} 9 | {% if advanced %} 10 |
11 |
12 |

13 | {% trans "Filter" %}: 14 | 15 | {% trans "Select" %}: 16 | 17 | 18 |

19 |
20 | 21 |
22 |

23 | 24 | 25 |

26 |
27 |
28 | {% endif %} 29 |
30 | 31 | 32 | 33 | {% if advanced %} 34 | 35 | {% endif %} 36 | 37 | 38 | 39 | {% if SHOW_SYLLABUS %} 40 | 41 | {% endif %} 42 | 43 | 44 | 45 | 46 | {% for course in courses %} 47 | {% blocktrans asvar remove_title %}Remove {{ course.code }} {{ course.name }}{% endblocktrans %} 48 | 49 | {% if advanced %} 50 | 53 | {% endif %} 54 | 63 | 72 | 75 | {% if SHOW_SYLLABUS %} 76 | 83 | {% endif %} 84 | 91 | 92 | {% endfor %} 93 | 94 |
{% trans "Course" %}{% trans "Alias" %}{% trans "Description" %}{% trans "Syllabus" %}{% trans "Exams" %}
51 | 52 | 55 | {% if course.url %} 56 | 57 | {{ course.code }} 58 | 59 | {% else %} 60 | {{ course.code }} 61 | {% endif %} 62 | 64 | {% if course.alias_form %} 65 | {% with index=tabindex|default:0|add:5 %} 66 | {{ course.alias_form.alias|tabindex:index }} 67 | {% endwith %} 68 | {% else %} 69 | {{ course.alias|default:"-" }} 70 | {% endif %} 71 | 73 | {{ course.name }} 74 | 77 | {% if course.syllabus %} 78 | {{ course.syllabus|hostname }} 79 | {% else %} 80 | - 81 | {% endif %} 82 | 85 | {% for exam in exams|get:course.id %} 86 | {{ exam.exam_date }}{% if not forloop.last %},{% endif %} 87 | {% empty %} 88 | - 89 | {% endfor %} 90 |
95 |
96 | {% endif %} 97 | 98 | {% if not courses or not advanced %} 99 |

100 | {% url 'schedule-advanced' semester.year semester.slug slug as advanced_url %} 101 | {% blocktrans %} 102 | Go to advanced options 103 | to add and remove courses. 104 | {% endblocktrans %} 105 |

106 | {% endif %} 107 |
108 | -------------------------------------------------------------------------------- /plan/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% load i18n %} 4 | {% load static %} 5 | 6 | {% block hd %} 7 | {{ block.super }} 8 |

{% trans "Couldn't add one or more courses" %}

9 | {% endblock %} 10 | 11 | {% block bd %} 12 |
13 | {% if to_many_subscriptions %} 14 |

15 | 16 | {% blocktrans %} 17 | Sorry, but the generator has been set up to limit 18 | timetables to {{ max }} courses. 19 | {% endblocktrans %} 20 |

21 | {% endif %} 22 | {% if courses %} 23 |

24 | 25 | {% trans "Adding the following courses failed:" %} 26 |

27 |
    28 | {% for c in courses %} 29 |
  • {{ c }}
  • 30 | {% endfor %} 31 |
32 | {% endif %} 33 |

34 | {% url 'schedule-advanced' year type slug as adavanced_url %} 35 | {% blocktrans %} 36 | Back to your schedule. 37 | {% endblocktrans %} 38 |

39 |
40 |

41 | {% blocktrans with INSTITUTION_SITE as url %} 42 | Please check that the courses that failed can be found at 43 | {{ url }} for this semster. 44 | {% endblocktrans %} 45 |

46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /plan/templates/groups.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 | {% if advanced %} 5 |

{% trans "Change groups" %}

6 | {% endif %} 7 |

8 | {% trans "Please choose the course variants you belong to." %} 9 |

10 |
11 | {% for course in courses %} 12 |
13 |
14 | {# TODO(adamcik): allow users to set alias in this form? #} 15 | {{ course.alias|default:course.code }} 16 | {% if course.name %} 17 |
18 | {{ course.name }} 19 | {% endif %} 20 |
21 | 25 |
26 | {% if course.group_form %} 27 | {{ course.group_form.groups }} 28 | {% else %} 29 |
    30 |
  • {% trans "No groups" %}
  • 31 |
32 | {% endif %} 33 |
34 |
35 | {% if advanced %} 36 | {% cycle "" "" "" %} 37 | {% endif %} 38 | {% endfor %} 39 |
40 |

41 | 42 |

43 |
44 | -------------------------------------------------------------------------------- /plan/templates/groups_link.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

{% trans "Change course groups" %}

4 |

5 | {% url 'change-groups' semester.year semester.slug slug as groups_url %} 6 | {% blocktrans %} 7 | Go to group selection page 8 | to change groups/parallels. 9 | {% endblocktrans %} 10 |

11 | -------------------------------------------------------------------------------- /plan/templates/lectures.html: -------------------------------------------------------------------------------- 1 | {% load compact %} 2 | {% load get %} 3 | {% load i18n %} 4 | 5 | {% if lectures %} 6 |

{% trans "Lecture list" %}

7 |
8 | {% if advanced %} 9 |
10 |
11 |

12 | {% trans "Filter" %}: 13 | 14 | {% trans "Select" %}: 15 | 16 | 17 |

18 |
19 |
20 |

21 | 22 |

23 |
24 |
25 | {% else %} 26 |

27 | {% url 'schedule-advanced' semester.year semester.slug slug as advanced_url %} 28 | {% blocktrans %} 29 | Go to advanced options 30 | to toggle which lectures to hide. 31 | {% endblocktrans %} 32 |

33 | {% endif %} 34 |
35 | 36 | 37 | 38 | {% if advanced %} 39 | 40 | {% endif %} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {% comment %} 53 | TODO: Setup toggle helper per course for table 54 | TODO: Setup proper tab ordering for select boxes 55 | {% endcomment %} 56 | {% for l in lectures %} 57 | {% blocktrans asvar exclude_title %}Hide {{ l }}{% endblocktrans %} 58 | 59 | {% if advanced %} 60 | 61 | {% endif %} 62 | 63 | 64 | 65 | 66 | 80 | 81 | 82 | 83 | 84 | {% endfor %} 85 | 86 |
{% trans "Course" %}{% trans "Day" %}{% trans "Time" %}{% trans "Info" %}{% trans "Rooms" %}{% trans "Type" %}{% trans "Groups" %}{% trans "Weeks" %}
{{ l.alias|default:l.course.code }}{{ l.get_day_display }}{{ l.start|time }}-{{ l.end|time }}{{l.title|default:"" }}{% if l.title and l.summary %} - {% endif %}{{ l.summary|default:"" }} 67 | {% if l.stream %} 68 | {% trans "Stream" %}{% if rooms|get:l.id %}, {% endif %} 69 | {% endif %} 70 | {% for room in rooms|get:l.id %} 71 | {% if room.url %} 72 | 73 | {{ room.name }}{% if not forloop.last %},{% endif %} 74 | {% else %} 75 | {{ room.name }}{% if not forloop.last %},{% endif %} 76 | {% endif %} 77 | {% endfor %} 78 | {% if not rooms|get:l.id %} {% endif %} 79 | {{l.type|default:"" }}{{ groups|get:l.id|join:", " }}{{ lecture_weeks|get:l.id|compact|join:", " }}
87 |
88 |
89 | {% endif %} 90 | -------------------------------------------------------------------------------- /plan/templates/notice.html: -------------------------------------------------------------------------------- 1 | {% now "Y-m-d" as today %} 2 | {% if today < "2019-08-27" %} 3 | 4 |
5 |

6 | Vil du drive med IT-systemer på høyt nivå med lav terskel? 7 | IT-komiteen 8 | søker nye medlemmer! 9 |   10 | Er ikke data noe for deg, finn andre verv på samfundet.no eller 11 | uka.no. 12 |

13 |
14 | 15 | {% endif %} 16 | -------------------------------------------------------------------------------- /plan/templates/schedule.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% load color %} 4 | {% load compress %} 5 | {% load i18n %} 6 | {% load nonce %} 7 | {% load static %} 8 | {% load title %} 9 | 10 | {% block extrastyle %} 11 | {{ block.super }} 12 | {% nonce CSP_NONCE %} 13 | {% compress css inline %} 14 | 15 | {% if courses %} 16 | 23 | {% endif %} 24 | {% if not courses or advanced %} 25 | 26 | 40 | {% endif %} 41 | {% endcompress %} 42 | {% endnonce %} 43 | {% endblock %} 44 | 45 | {% block extrahead %} 46 | {{ block.super }} 47 | 48 | {% if next_week %} 49 | 50 | {% endif %} 51 | {% endblock %} 52 | 53 | 54 | {% block extrascript %} 55 | {% if courses and not advanced %} 56 | {% nonce CSP_NONCE %} 57 | {% compress js inline %} 58 | 59 | {% endcompress %} 60 | {% endnonce %} 61 | {% endif %} 62 | {% if not courses or advanced %} 63 | {% nonce CSP_NONCE %} 64 | {% compress js %} 65 | 66 | 67 | 68 | 69 | {% endcompress %} 70 | {% endnonce %} 71 | {% endif %} 72 | {% endblock %} 73 | 74 | {% block title %} 75 | {% title semester slug week %} 76 | {% endblock %} 77 | 78 | {% block hd %} 79 | {{ block.super }} 80 |

81 | {% title semester slug week %} 82 |

83 | {% endblock %} 84 | 85 | {% block bd %} 86 | {% url 'schedule-advanced' semester.year semester.slug slug as advanced_url %} 87 | 88 | {% include "notice.html" %} 89 | 90 | {% if not courses %} 91 |
92 | {% include "add_courses.html" %} 93 |
94 | {% endif %} 95 | 96 | {% include "schedule_message.html" %} 97 | {% include "schedule_table.html" %} 98 | {% include "schedule_table_footer.html" %} 99 | 100 | {% if courses %} 101 | {% if advanced %} 102 | {% include "courses.html" with tabindex=10 %} 103 |
104 |
105 | {% include "add_courses.html" with tabindex=20 %} 106 |
107 |
108 | {% include "groups_link.html" %} 109 |
110 |
111 | {% endif %} 112 | {% endif %} 113 | 114 | {% include "lectures.html" with tabindex=30 %} 115 | {% if not advanced %} 116 | {% include "courses.html" %} 117 | {% endif %} 118 | {% include "tips.html" %} 119 | {% endblock %} 120 | -------------------------------------------------------------------------------- /plan/templates/schedule_message.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if next_message and not group_help and courses %} 4 |
5 |

6 | 7 | {% url 'schedule' next_semester.year next_semester.slug slug as next_semester_url %} 8 | {% blocktrans %} 9 | Next semesters' lectures are now available, go to 10 | {{ next_semester_url }} 11 | to start adding your courses. 12 | {% endblocktrans %} 13 |

14 |
15 | {% endif %} 16 | -------------------------------------------------------------------------------- /plan/templates/schedule_table.html: -------------------------------------------------------------------------------- 1 | {% load get %} 2 | {% load i18n %} 3 | {% load nbsp %} 4 | {% load strip %} 5 | 6 | {% if prev_week %} 7 | 8 | {% endif %} 9 | {% if next_week %} 10 | 11 | {% endif %} 12 |
13 | 14 | 15 | 16 | 17 | {% for span, date, day in timetable.header %} 18 | 22 | {% endfor %} 23 | 24 | 25 | 26 | {% for row in timetable.table %} 27 | 28 | {% for cells in row %} 29 | {% if cells %} 30 | {% for c in cells %} 31 | {% if not c.remove %} 32 | 81 | {% endif %} 82 | {% endfor %} 83 | {% else %} 84 | 85 | {% endif %} 86 | {% endfor %} 87 | 88 | {% endfor %} 89 | 90 |
19 | {{ day }} 20 | {% if date %}{{ date|date:"j M." }}{% endif %} 21 |
1 %}colspan="{{ c.colspan }}"{% endif %} 33 | {% if c.rowspan > 1 %}rowspan="{{ c.rowspan }}"{% endif %} 34 | {% if c.lecture or c.time or c.bottom or c.last %} 35 | class="{% stripspace %} 36 | {% if c.lecture %} 37 | lecture 38 | lecture-{{ c.lecture.id }} 39 | course-{{ c.lecture.course_id }} 40 | {% endif %} 41 | {% if c.lecture.optional %} 42 | optional 43 | {% endif %} 44 | {% if c.rowspan == 1 %} 45 | single 46 | {% endif %} 47 | {% if c.last %} 48 | last 49 | {% endif %} 50 | {% if c.bottom %} 51 | bottom 52 | {% endif %} 53 | {% if c.time %} 54 | time 55 | {% endif %} 56 | {% endstripspace %}" 57 | {% endif %} 58 | {% if c.lecture %}title="{{ c.lecture.course.name }} {{ c.lecture.start|time }}-{{ c.lecture.end|time }}{% if c.lecture.title %}: {{ c.lecture.title }}{% endif %}"{% endif %} 59 | > 60 |
61 | {% if c.lecture %} 62 |
{{ c.lecture.alias|default:c.lecture.course.code }}
63 |
64 | {% if c.lecture.stream %} 65 | {% trans "Stream" %}{% if rooms|get:c.lecture.id %}, {% endif %} 66 | {% endif %} 67 | {% for room in rooms|get:c.lecture.id|slice:":2" %} 68 | {% if room.url %} 69 | 70 | {{ room.name }}{% if not forloop.last %},{% endif %} 71 | {% else %} 72 | {{ room.name }}{% if not forloop.last %},{% endif %} 73 | {% endif %} 74 | {% endfor %} 75 |
76 |
{% if c.lecture.title %}{{ c.lecture.title }}{% elif c.lecture.type %}{{ c.lecture.type }}{% endif %}
77 | {% endif %} 78 | {{ c.time|escape|nbsp|default:"" }} 79 |
80 |
91 |
92 | -------------------------------------------------------------------------------- /plan/templates/schedule_table_footer.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if courses %} 4 |
5 |
6 |

7 | {% if advanced %} 8 | {% trans "Hide advanced options" %} 9 | {% else %} 10 | {% trans "Advanced options" %} 11 | {% endif %} 12 | - {% trans "Tips" %} 13 | - 14 | {% if week %} 15 | A4 16 | A5 17 | A6 18 | A7 19 | {% else %} 20 | A4 21 | A5 22 | A6 23 | A7 24 | {% endif %} 25 |

26 |
27 |
28 |

29 | {% trans "Weeks:" %} 30 | {% trans "All" %} 31 | {% if not week_is_current %} 32 | {% trans "Current" %} 33 | {% endif %} 34 | {% for w in weeks %} 35 | {{ w }} 36 | {% endfor %} 37 |

38 |
39 |
40 | {% endif %} 41 | -------------------------------------------------------------------------------- /plan/templates/select_groups.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% load color %} 4 | {% load compress %} 5 | {% load i18n %} 6 | {% load nonce %} 7 | {% load static %} 8 | 9 | {% block extrastyle %} 10 | {% nonce CSP_NONCE %} 11 | {% compress css inline %} 12 | {% if courses %} 13 | 20 | {% endif %} 21 | 22 | {% endcompress %} 23 | {% endnonce %} 24 | {% endblock %} 25 | 26 | {% block extrascript %} 27 | {% compress js %} 28 | 29 | {% endcompress %} 30 | {% endblock %} 31 | 32 | {% block title %} 33 | {% trans "Select groups for your courses" %} 34 | {% endblock %} 35 | 36 | {% block hd %} 37 | {{ block.super }} 38 |

39 | {% trans "Select groups for your courses" %} 40 |

41 | {% endblock %} 42 | 43 | {% block bd %} 44 | {% include "groups.html" %} 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /plan/templates/setlang.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% get_current_language as LANGUAGE_CODE %} 4 | {% get_available_languages as LANGUAGES %} 5 | 6 |

7 | {% for code, name in LANGUAGES %} 8 | {% ifnotequal LANGUAGE_CODE code %} 9 | {% language code %} 10 | {% trans "Switch to English" %} 11 | {% endlanguage %} 12 | {% endifnotequal %} 13 | {% endfor %} 14 |

15 | -------------------------------------------------------------------------------- /plan/templates/start.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% load color %} 4 | {% load compress %} 5 | {% load i18n %} 6 | {% load nonce %} 7 | {% load static %} 8 | {% load strip %} 9 | 10 | {% block hd %} 11 | {{ block.super }} 12 |

13 | {% blocktrans with INSTITUTION as institution %} 14 | Timetable generator for {{ institution }} students 15 | {% endblocktrans %} 16 |

17 | {% endblock %} 18 | 19 | {% block extrascript %} 20 | {% nonce CSP_NONCE %} 21 | {% compress js inline %} 22 | 36 | {% endcompress %} 37 | {% endnonce %} 38 | {% endblock %} 39 | 40 | {% block extrastyle %} 41 | {% nonce CSP_NONCE %} 42 | {% compress css inline %} 43 | {% if stats %} 44 | 52 | {% endif %} 53 | 54 | {% endcompress %} 55 | {% endnonce %} 56 | {% endblock %} 57 | 58 | {% block bd %} 59 |
60 |
61 |

{% trans "Getting started" %}

62 | {% if next_semester %} 63 | {% url 'semester' next_semester.year next_semester.slug as next_semester_url %} 64 |

65 | 66 | {% blocktrans with url=next_semester_url %} 67 | A new semester is available, click here to get started. 68 | {% endblocktrans %} 69 |

70 | {% endif %} 71 |
72 |
73 | 81 |

82 | {{ schedule_form.semester }} 83 | {{ schedule_form.slug }} 84 | 85 |

86 |
87 |
88 |

89 | {% blocktrans %} 90 | To retrieve your timetable simply enter the same identifier 91 | you used last time. 92 | {% endblocktrans %} 93 |

94 | {% include "notice.html" %} 95 | {% include "statistics.html" %} 96 |
97 |
98 |

99 | {% blocktrans %} 100 | Top {{ limit }} courses {{ current }} 101 | {% endblocktrans %} 102 |

103 | {% if stats %} 104 |
105 | {% for count,course_id,code,name in stats %} 106 |
107 | {{ count }} 108 | {{ code }} 109 |
110 | {% endfor %} 111 |
112 | {% else %} 113 | {% trans "No courses have been added to any schedules yet." %} 114 | {% endif %} 115 |
116 |
117 |
118 | {% endblock %} 119 | -------------------------------------------------------------------------------- /plan/templates/statistics.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

4 | {% blocktrans %} 5 | Statistics {{ current }} 6 | {% endblocktrans %} 7 |

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
{% trans "Total number of unique timetables" %}{{ slug_count }}
{% trans "Total number of unique courses added" %}{{ course_count }}
19 | -------------------------------------------------------------------------------- /plan/templates/tips.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% url 'shortcut' slug as shortcut_url %} 4 | {% url 'course-list' semester.year semester.slug slug as course_list_url %} 5 | {% url 'schedule-advanced' semester.year semester.slug slug as adavanced_url %} 6 | 7 | {% url 'schedule-ical' semester.year semester.slug slug _('lectures') as ical_lectures_url%} 8 | {% url 'schedule-ical' semester.year semester.slug slug _('exams') as ical_exams_url %} 9 | {% url 'schedule-ical' semester.year semester.slug slug as ical_all_url %} 10 | 11 |

{% trans "Tips" %}

12 |
13 |
14 |
15 |
16 |
17 | {% trans "Multiple courses can be added at once by separating course codes with spaces." %} 18 |
19 | 20 |
21 | {% blocktrans with request.META.HTTP_HOST as host %} 22 | For quick access to your schedule simply go to 23 | {{ host }}{{ shortcut_url }} 24 | {% endblocktrans %} 25 |
26 |
27 |
28 | {% blocktrans %} 29 | Did you know that you can choose to hide lectures that you don't want to be shown under 30 | advanced options? 31 | {% endblocktrans %} 32 |
33 |
34 |
35 | {% blocktrans %} 36 | Did you know that you can select which groups (paralleller) you 37 | want to attend, and than you can select more than one group? 38 | {% endblocktrans %} 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | {% blocktrans %} 47 | Did you know that you can change which names are used for courses under 48 | advanced options? 49 | {% endblocktrans %} 50 |
51 |
52 |
53 | {% blocktrans %} 54 | Calendar export: 55 | lectures, 56 | exams or 57 | both combined. 58 | {% endblocktrans %} 59 |
60 | {% trans "Simply download or copy the link to the calendar you want and add it to any application that supports the Ical format." %} 61 |
62 | 63 |
64 | {% trans "Google calendar:" %} 65 | {% trans "lectures" %}, 66 | {% trans "exams" %} {% trans "or" %} 67 | {% trans "both combined" %}. 68 |
69 | {% trans "Simply click the calendar type you want and it will be added to Google calendar." %} 70 |
71 |
72 |
73 |
74 | -------------------------------------------------------------------------------- /plan/templates/title.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if week %} 4 | {% blocktrans %} 5 | {{ slug }}{{ ending }} 6 | {{ type }} 7 | {{ year }} schedule 8 | for week {{ week }} 9 | {% endblocktrans %} 10 | {% else %} 11 | {% blocktrans %} 12 | {{ slug }}{{ ending }} 13 | {{ type }} 14 | {{ year }} schedule 15 | {% endblocktrans %} 16 | {% endif %} 17 | -------------------------------------------------------------------------------- /plan/urls.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | from django.conf import settings 4 | from django.conf.urls import * 5 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 6 | 7 | handler500 = "plan.common.utils.server_error" 8 | 9 | if settings.DEBUG: 10 | from django.views.generic.base import TemplateView 11 | 12 | urlpatterns = [ 13 | url(r"^500/$", TemplateView.as_view(template_name="500.html")), 14 | url(r"^404/$", TemplateView.as_view(template_name="404.html")), 15 | ] 16 | else: 17 | urlpatterns = [] 18 | 19 | urlpatterns += [ 20 | url(r"^", include("plan.common.urls")), 21 | url(r"^", include("plan.ical.urls")), 22 | url(r"^", include("plan.pdf.urls")), 23 | ] 24 | 25 | # This will only be active when DEBUG=False or --insecure is set 26 | urlpatterns += staticfiles_urlpatterns() 27 | -------------------------------------------------------------------------------- /plan/wsgi.py: -------------------------------------------------------------------------------- 1 | # This file is part of the plan timetable generator, see LICENSE for details. 2 | 3 | import os 4 | 5 | """WSGI config for plan project. 6 | 7 | This module contains the WSGI application used by Django's development server 8 | and any production WSGI deployments. It should expose a module-level variable 9 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 10 | this application via the ``WSGI_APPLICATION`` setting. 11 | """ 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plan.settings") 14 | 15 | from django.core.wsgi import get_wsgi_application 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | authors = [{ name = "Thomas Adamcik", email = "thomas@adamcik.no" }] 3 | license = { text = "AGLv3" } 4 | requires-python = "<4.0,>=3.9" 5 | dependencies = [ 6 | "Django~=3.2.12", 7 | "django-compressor~=2.4", 8 | "lxml~=4.6.3", 9 | "psycopg2~=2.8.6", 10 | "requests~=2.25.1", 11 | "tqdm", 12 | "typing-extensions>=4.12.2", 13 | "vobject~=0.9.6.1", 14 | ] 15 | name = "plan" 16 | version = "2.0.0" 17 | readme = "README.md" 18 | description = "Timetable generator for educational institutions." 19 | 20 | [project.urls] 21 | repository = "https://github.com/adamcik/plan" 22 | 23 | # TODO: Is this needed? 24 | [build-system] 25 | requires = ["hatchling"] 26 | build-backend = "hatchling.build" 27 | 28 | [dependency-groups] 29 | dev = [ 30 | "django-stubs~=4.2.7", 31 | ] 32 | -------------------------------------------------------------------------------- /static/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | --------------------------------------------------------------------------------