Browse Source

Merge remote-tracking branch 'gitea/master' into zadavatko_problemu

zadavatko_problemu
Pavel "LEdoian" Turinsky 2 years ago
parent
commit
e6f5bb4975
  1. 159
      Makefile
  2. 135
      README.md
  3. 11
      api/tests/test_skola_autocomplete.py
  4. 1
      api/urls.py
  5. 25
      api/views/autocomplete.py
  6. 10
      data/flat.json
  7. 1
      docs/april.rst
  8. 14
      docs/conf.py
  9. 19
      docs/index.rst
  10. 108
      docs/skripty.rst
  11. 4
      docs/sphinx.rst
  12. 31
      docs/struktura.rst
  13. 25
      docs/tabulka_prerekvizit.rst
  14. 200
      docs/vyvoj.rst
  15. 5
      docs/zapisy/zapisy.rst
  16. 84
      header_fotky/context_processors.py
  17. 18
      korektury/migrations/0019_auto_20221205_2014.py
  18. 2
      korektury/models.py
  19. 2
      korektury/templates/korektury/opraf.html
  20. 34
      korektury/views.py
  21. 8
      make/README.md
  22. 31
      make/deploy
  23. 25
      make/deploy_prod
  24. 10
      make/init_local
  25. 1
      make/install
  26. 16
      make/install_web
  27. 115
      make/lib.sh
  28. 14
      make/push_compiled_vue_to_test
  29. 8
      make/run
  30. 9
      make/schema
  31. 20
      make/sync_prod_flatpages
  32. 11
      make/sync_test
  33. 18
      make/sync_test_db_aggressive
  34. 9
      make/sync_test_media
  35. 9
      make/test
  36. 32
      mamweb/admin.py
  37. 152
      mamweb/middleware.py
  38. 386
      mamweb/settings_common.py
  39. 99
      mamweb/settings_local.py
  40. 20
      mamweb/settings_prod.py
  41. 28
      mamweb/settings_test.py
  42. 24
      mamweb/static/css/mamweb.css
  43. 16
      odevzdavatko/forms.py
  44. 22
      odevzdavatko/static/odevzdavatko/check_for_detail.js
  45. 56
      odevzdavatko/static/odevzdavatko/dynamic_formsets_for_detail.js
  46. 120
      odevzdavatko/templates/odevzdavatko/detail.html
  47. 51
      odevzdavatko/templates/odevzdavatko/detail_resitele.html
  48. 2
      odevzdavatko/templates/odevzdavatko/nahraj_reseni.html
  49. 2
      odevzdavatko/templates/odevzdavatko/tabulka.html
  50. 4
      odevzdavatko/urls.py
  51. 80
      odevzdavatko/views.py
  52. 2
      personalni/admin.py
  53. 19
      personalni/forms.py
  54. 1
      personalni/templates/personalni/udaje/edit.html
  55. 2
      personalni/templates/personalni/udaje/gdpr.html
  56. 1
      personalni/templates/personalni/udaje/prihlaska.html
  57. 2
      personalni/views.py
  58. 18
      seminar/migrations/0110_resitel_prezdivka.py
  59. 18
      seminar/migrations/0111_nikam2nezasilat_papirove.py
  60. 18
      seminar/models/base.py
  61. 42
      seminar/models/novinky.py
  62. 234
      seminar/models/odevzdavatko.py
  63. 4
      seminar/models/personalni.py
  64. 32
      seminar/models/tvorba.py
  65. 2
      seminar/templatetags/deadliny.py
  66. 18
      seminar/testutils.py
  67. 20
      seminar/views/views_all.py
  68. 50
      soustredeni/admin.py
  69. 90
      treenode/admin.py
  70. 4
      treenode/permissions.py
  71. 24
      various/autentizace/utils.py
  72. 8
      various/log_filters.py
  73. 0
      various/templatetags/__init__.py
  74. 48
      various/templatetags/mail.py
  75. 59
      various/tests.py
  76. 4
      vysledkovky/utils.py

159
Makefile

@ -1,154 +1,9 @@
PYTHON ?= python3 # Existence tohohle Makefile je tu jen proto, aby fungovala svalová paměť. Pokud můžete, použijte rovnou `make/…`
VENV ?= ${PYTHON} -m venv %:
# Všechny flagy, které se s venvem/virtualenvem/... mají volat patří sem. Volá se "${VENV} cesta" # Používání make jako příkazu je zastaralé, prosím používej radši skripty ze složky make. Spouštím následující příkaz:
VENV_PATH ?= env make/$*
# Musí být definovaná, i kdyby to měla být "."
.PHONY: all venv_check clean install install_web install_venv clean_venv clean_schema run test deploy_test deploy_prod sync_test_media sync_test_db sync_test sync_local_media sync_local_db sync_local default:
@cat make/README.md
# activate by mělo být předpokladem ke všemu, co volá webový python (i.e. python nasazený do ${VENV}u kvůli webu, např. manage.py) .PHONY: default
all:
@# Just echo:
# Install je trochu magický:
# Spusť následující posloupnost příkazů:
# make install_venv
# . ${VENV_PATH}/bin/activate
# make install_web
#
# Pokud install_web říká Error: pg_config executable not found. nainstaluj si libpq-dev
# Pokud chybová hláška obsahuje #include <Python.h>, nainstaluj si python3-dev
#
# Až skončíš s vývojem webu, spusť "deactivate". Tím zmizí '(${VENV_PATH})' ze začátku promptu.
venv_check:
@# Pokud org nemá zapnutý venv, poradíme mu, aby si ho zapnul a spadneme. Jinak nic.
@expr $$PATH : ".*:*$(shell pwd)/${VENV_PATH}/bin" > /dev/null && exit 0 || echo 'Není zapnutý venv, spusť ". ${VENV_PATH}/bin/activate".\nPokud není venv nainstalovaný, spusť "make install_venv"' && false
clean: clean_venv clean_schema
install: install_web
install_web: venv_check
@# venv může být příšerně starý, takže nejdříve upgradujeme venvové věci
pip install --upgrade pip
pip install --upgrade setuptools
# Instalace závislostí webu
pip install -r requirements.txt --upgrade
# Pro vygenerování tesdat spusť ./manage.py testdata
# Po vygenerování testdat spusť ./manage.py loaddata data/*, ať máš menu a další modely
# Pro synchronizaci flatpages spusť make sync_prod_flatpages
install_venv:
${VENV} ${VENV_PATH}
clean_venv:
# Možná není 100% foolproof...
@test ! ${VENV_PATH} = . || ! echo "Smaž si prosím venv sám, nebudu mazat celý web"
rm -rfv ${VENV_PATH}
rm -f pip-selfcheck.json
clean_schema:
rm -f schema_seminar.pdf schema_all.pdf
run: venv_check
./manage.py runserver
test: venv_check
./manage.py test -v2 seminar mamweb
# DB schemata
schema: schema_seminar.pdf schema_all.pdf
schema_seminar.pdf: venv_check
./manage.py graph_models seminar | dot -Tpdf > schema_seminar.pdf
schema_all.pdf: venv_check
./manage.py graph_models -a -g | dot -Tpdf > schema_all.pdf
# Deploy to current *mamweb-test* directory
deploy_test: venv_check
@if [ ${USER} != "mam-web" ]; then echo "Only possible by user mam-web"; exit 1; fi
@if [ `readlink -f .` != "/aux/akce/mam/www/mamweb-test" ]; then echo "Only possible in directory mamweb-test"; exit 1; fi
@echo "Installing version from origin/test ..."
git pull origin test
git clean -f
make install
./manage.py migrate
./manage.py collectstatic --noinput
(chown -R :mam . || true )
(chmod -R g+rX,go-w . || true )
@echo Restarting systemd unit
systemctl --user restart mamweb-test.service
@echo Done.
# Deploy to current *mamweb-prod* directory
deploy_prod: venv_check
@if [ ${USER} != "mam-web" ]; then echo "Only possible by user mam-web"; exit 1; fi
@if [ `readlink -f .` != "/aux/akce/mam/www/mamweb-prod" ]; then echo "Only possible in directory mamweb-prod"; exit 1; fi
@echo "Backing up production DB ..."
( cd -P .. && ./backup_prod_db.sh )
@echo "Installing version from origin/master ..."
git pull origin master
git clean -f
make install
./manage.py migrate
./manage.py collectstatic --noinput
(chown -R :mam . || true )
(chmod -R g+rX,go-w . || true )
@echo Restarting systemd user unit for MaM web
systemctl --user restart mamweb-prod.service
@echo Done.
sync_prod_flatpages: venv_check
@echo Downloading current version of flatpages from mamweb-prod.
ssh mam-web@gimli.ms.mff.cuni.cz \
"cd /akce/mam/www/mamweb-prod; . env/bin/activate; ./manage.py dumpdata flatpages --indent=2 > flat.json; ./fix_json.py flat.json flat_fixed.json"
rsync -ave ssh mam-web@gimli.ms.mff.cuni.cz:/akce/mam/www/mamweb-prod/flat_fixed.json data/flat.json
@echo "Applying downloaded flatpages."
./manage.py loaddata data/flat.json
@echo "Done."
# Sync test media directory with production
sync_test_media:
@if [ ${USER} != "mam-web" ]; then echo "Only possible by user mam-web"; exit 1; fi
@if [ `readlink -f .` != "/aux/akce/mam/www/mamweb-test" ]; then echo "Only possible in /akce/mam/www/mamweb-test"; exit 1; fi
rsync -av --delete /akce/mam/www/mamweb-prod/media/ ./media
# Sync (with drop) test database with production database
sync_test_db_aggressive:
@if [ ${USER} != "mam-web" ]; then echo "Only possible by user mam-web"; exit 1; fi
pg_dump mam_test > dump-test-`date +"%Y%m%d_%H%M"`.sql
pg_dump -Fc mam_prod > dump-prod.sql
@# I am not sure which shell is used, so I am calling bash to make sure
psql mam_test -c 'DROP OWNED BY "mam-web";'
pg_restore -c --if-exists -d mam_test dump-prod.sql
rm dump-prod.sql
psql mam_test -c "UPDATE django_site SET name='MaMweb (test)', domain='mam-test.ks.matfyz.cz' WHERE id=1"
@echo Done.
# Sync test with production
# HACK ALERT: using aggressive variant, due to the schemas being too different.
sync_test: sync_test_media sync_test_db_aggressive
# Sync media directory with atrey. Useful for local development with production database
# Does not sync Galerie and CACHE (too huge).
sync_local_media:
rsync -ave ssh --exclude Galerie --exclude CACHE\
mam-web@gimli.ms.mff.cuni.cz:/akce/mam/www/mamweb-prod/media/ ./media/
# Downloads and restores production database to local database. PostgreSQL only.
sync_local_db:
scp mam-web@gimli.ms.mff.cuni.cz:`ssh mam-web@gimli.ms.mff.cuni.cz 'ls -v /akce/mam/www/backups/mam_prod-*\.pgdump.xz | tail -n 1'` \
./last.pgdump.xz
xz -fd last.pgdump.xz
pg_restore -c -d mam-prod last.pgdump
# Sync database and media. See above lines
sync_local: sync_local_media sync_local_db
# Push local compiled Vue to gimli test site
push_compiled_vue_to_test:
scp vue_frontend/webpack-stats.json mam-web@gimli:/akce/mam/www/mamweb-test/vue_frontend/
rsync -ave ssh seminar/static/seminar/vue mam-web@gimli:/akce/mam/www/mamweb-test/seminar/static/seminar/
ssh mam-web@gimli.ms.mff.cuni.cz 'cd /akce/mam/www/mamweb-test/ && . env/bin/activate && ./manage.py collectstatic --noinput'

135
README.md

@ -1,77 +1,58 @@
Basic commands for web development Web M&M
================================== ======
After you clone this repository, run `make`. It will download, locally install Tohle je repozitář s kódem M&Mího webu. Pokud zde hledáte web samotný nebo
and setup virtualenv and pip, and then locally install all required packages informace o semináři, najdete je na <https://mam.matfyz.cz> (a upřímně nechápu,
from `requirements.txt`. jak jste se dostali k tomuhle textu :-D)
When working with the code, always use the binaries in `./bin/`, such as Pokud jste tu zůstali, tak vás beztak zajímá vývoj webu (a jestli ne, tak
`bin/pip`, `bin/python`, ... never the global python, pip, ... budeme rádi, když začne :-)).
Use `make` and `./manage.py` for most things.
Co je M&Mweb uvnitř
Use git :-) ------
Celý náš web je napsaný v [Pythonu](https://www.python.org) ve frameworku
Quickstart [Django](https://www.djangoproject.com/). Web běží na serveru zvaném Gimli,
---------- jako databázi používá PostgreSQL (pro lokální vývoj naopak SQLite) a všechen
Run the following commands: náš kód je uložený v [Gitu](https://git-scm.com/) na [téhle
make install_venv gitee](https://gitea.ks.matfyz.cz/). Pro dokumentaci používáme
. env/bin/activate [Sphinx](https://www.sphinx-doc.org).
make install_web
<!--TODO: Z odstavce výše by ideálně měla být zachována jen první věta a zbytek
Pokud install_web říká Error: pg_config executable not found. nainstaluj si libpq-dev by měl být někde v docs s podrobnějším popisem…-->
Pokud chybová hláška obsahuje #include <Python.h>, nainstaluj si python3-dev
Jak si web pořídit
After finishing development, run "deactivate". ------
Prosím přečti si podrobnější návod v <docs/vyvoj.rst> (tady by bylo zbytečné
Make commands ho duplikovat).
-------------
Jak web vyvíjet
* `make install` (or `make`) - locally install and setup virtualpy, install ----
required packages. Ran again installs missing packages. Run after changing <!--TODO: Napsat obšírněji, asi zase do docs/-->
`requirements.txt`.
Na webu je mnoho věcí k dělání, některé ani nevyžadují kódění (třeba uhánění
* `make clean` - remove local python packages. orgů, aby si psali medailonky, aktualizace fotek, …), některé se naopak týkají
infrastruktury pod kódem (Gitea, Gimli, …). Je proto těžké mít na to úplně
* `make veryclean` - remove local packages and virtualpy enviroment and obecný návod, tak tady je zhruba návod na úpravy kódu a pokud se něco z toho
binaries. nedá aplikovat, tak to prostě zkus nějak udělat jinak, po svém. (Omlouvám se
neinformatikům, ale líp to teď nesepíšu :-))
* `make run` - runs "./manage.py runserver_plus"
1. Nejprve si stáhni repozitář a rozběhni si lokální web u sebe (viz <docs/vyvoj.rst>).
* `make push_test` - pushes the last commited version to test location. 1. Najdi si problém v Kanboardu (klikni na „Issues“ na Gitee) a/nebo se domluv
Only git-commited changes are pushed! Rest is re-generated from scratch. s webaři, na čem bys tak mohl/a pracovat.
At test server, the media data and database are kept the same. 1. Najdi místo, kde se to dá opravit a zkus to tam opravit. Uznávám, že tenhle
Everything else not in .gitignore is deleted/overwritten on the test server. bod je otravně obecný, pokud tápeš, zkus se zeptat zkušenějších webařů nebo
podívat do dokumentace.
* `make schema` - generates graph of seminar and all schemas as PDF. Supercool! 1. Vyzkoušej, že ti to lokálně funguje tak, jak má.
1. Zvládneš-li a máš-li čas, zkus to i zdokumentovat a/nebo napsat testy (TODO: chybí návod)
* `make sync_prod_flatpages` - downloads and applies static/flat pages from mamweb-prod 1. Po dohodě s webaři to vyzkoušej na testwebu
1. Pošli pull-request a případně zkus reagovat na komentáře
./manage.py commands 1. Až se změna začlení do hlavní větve (`master`) a nasadí se web na produkci,
-------------------- můžeš mít radost, že se web bude používat lépe Tobě i ostatním orgům :-)
* `./manage.py migrate` - update the database schema, initialise the database. ### Proč pull-requesty?
You need to run this in the beginning. <!--FIXME: Tohle ale už úplně patří do docs a ne sem, jen je zatím nemám prozkoumané…-->
* `./manage.py runserver_plus` - run a debugging server for the web. Slightly Účelů pull-requestů je několik. Jednak doufáme, že pomůže webařům se orientovat
enhanced compared to `./manage.py runserver`. v kódu, jednak tím umožňujeme dělat experimenty a dávat si zpětnou vazbu. V
Open [127.0.0.1:8000](127.0.0.1:8000). neposlední řadě pomáhají držet aspoň trochu konzistentní kód, což má pomoci
pohodě při programování… (A asi jsem na něco zapomněl :-))
* `./manage.py testdata` - create pseudo-random seminar data and admin/admin
user.
* `./manage.py test` - run the tests.
* `./manage.py shell` - run commands, list elemements of database, check syntax
by importing files, etc.
Configurations
--------------
* `mamweb/settings_common.py` contains most configuration options.
* `mamweb/settings.py` is used only for local development.
* `mamweb/settings_test.py` is used for testing on atrey.
* `mamweb/settings_prod.py` is used in production deployment.
These are automatically switched by `make`.

11
api/tests/test_skola_autocomplete.py

@ -11,19 +11,20 @@ class OrgSkolyAutocompleteTestCase(TestCase):
sync_skoly('https://mam.mff.cuni.cz/') sync_skoly('https://mam.mff.cuni.cz/')
# Správné školy podle toho, co orgové poslali: (prefix, ID školy) # Správné školy podle toho, co orgové poslali: (prefix, ID školy)
# NOTE: Pozor, jedná se o databázové indexy. Pokud se to někdy rozbije, bude potřeba je přepsat nebo předělat na IZO # NOTE: Pozor, jedná se o databázové indexy. Pokud se to někdy rozbije, bude potřeba je přepsat nebo předělat na IZO
# TODO: Opravit zakomentované školy.
cls.spravna_data = [ cls.spravna_data = [
('gymnázium kolín', 53), ('gymnázium kolín', 53),
('kolín', 53), ('kolín', 53),
('gasoš', 96), #('gasoš', 96),
('Rokycany', 96), ('Rokycany', 96),
('gasoš Rokycany', 96), #('gasoš Rokycany', 96),
('SPŠE Pardubice', 815), #('SPŠE Pardubice', 815),
('Jaroše', 164), ('Jaroše', 164),
("Gymnázium, Brno, tř. Kpt. Jaroše", 164), #("Gymnázium, Brno, tř. Kpt. Jaroše", 164),
("Jírovcova", 157), ("Jírovcova", 157),
('České Budějovice', 157), ('České Budějovice', 157),
("Gymnázium, České Budějovice, Jírovcova 8", 157), ("Gymnázium, České Budějovice, Jírovcova 8", 157),
("první soukromé", 2), #("první soukromé", 2),
("Gymnázium Elgartova", 147), ("Gymnázium Elgartova", 147),
("Jihlava", 45), ("Jihlava", 45),
('Milevsko', 223), ('Milevsko', 223),

1
api/urls.py

@ -22,6 +22,7 @@ urlpatterns = [
# Autocomplete # Autocomplete
path('api/autocomplete/skola/', views.SkolaAutocomplete.as_view(), name='autocomplete_skola'), path('api/autocomplete/skola/', views.SkolaAutocomplete.as_view(), name='autocomplete_skola'),
path('api/autocomplete/resitel/', org_required(views.ResitelAutocomplete.as_view()), name='autocomplete_resitel'), path('api/autocomplete/resitel/', org_required(views.ResitelAutocomplete.as_view()), name='autocomplete_resitel'),
path('api/autocomplete/resitel_public/', views.PublicResitelAutocomplete.as_view(), name='autocomplete_resitel_public'),
path('api/autocomplete/problem/odevzdatelny', views.OdevzdatelnyProblemAutocomplete.as_view(), name='autocomplete_problem_odevzdatelny'), path('api/autocomplete/problem/odevzdatelny', views.OdevzdatelnyProblemAutocomplete.as_view(), name='autocomplete_problem_odevzdatelny'),
path('api/autocomplete/problem/vsechny', views.ProblemAutocomplete.as_view(), name='autocomplete_problem'), path('api/autocomplete/problem/vsechny', views.ProblemAutocomplete.as_view(), name='autocomplete_problem'),

25
api/views/autocomplete.py

@ -12,7 +12,7 @@ from .helpers import LoginRequiredAjaxMixin
class SkolaAutocomplete(autocomplete.Select2QuerySetView): class SkolaAutocomplete(autocomplete.Select2QuerySetView):
""" View k :mod:`dal.autocomplete` pro vyhledávání škol hlavně při registraci. """ """ View k :mod:`dal.autocomplete` pro vyhledávání škol hlavně při registraci. """
def get_queryset(self): def get_queryset(self):
# Don't forget to filter out results depending on the visitor ! # Don't forget to filter out results depending on the visitor !
qs = m.Skola.objects.all() qs = m.Skola.objects.all()
if self.q: if self.q:
words = self.q.split(' ') #TODO re split podle bileho znaku words = self.q.split(' ') #TODO re split podle bileho znaku
@ -44,6 +44,29 @@ class ResitelAutocomplete(LoginRequiredAjaxMixin,autocomplete.Select2QuerySetVie
qs = qs.filter(query) qs = qs.filter(query)
return qs return qs
class PublicResitelAutocomplete(LoginRequiredAjaxMixin, autocomplete.Select2QuerySetView):
"""
View k :mod:`dal.autocomplete` pro vyhledávání řešitelů podle přezdívky
především v odevzdávátku.
"""
def get_queryset(self):
letos = m.Nastaveni.get_solo().aktualni_rocnik
qs = m.Resitel.objects.filter(
rok_maturity__gte=letos.druhy_rok()
).filter(
prezdivka_resitele__isnull=False
).exclude(
prezdivka_resitele=""
).filter(
prezdivka_resitele__icontains=self.q
).all()
return qs
def get_result_label(self, result):
return result.prezdivka_resitele
class OdevzdatelnyProblemAutocomplete(autocomplete.Select2QuerySetView): class OdevzdatelnyProblemAutocomplete(autocomplete.Select2QuerySetView):
""" View k :mod:`dal.autocomplete` pro vyhledávání problémů především v odevzdávátku. """ """ View k :mod:`dal.autocomplete` pro vyhledávání problémů především v odevzdávátku. """
def get_queryset(self): def get_queryset(self):

10
data/flat.json

File diff suppressed because one or more lines are too long

1
docs/april.rst

@ -2,3 +2,4 @@ Aprílové nápad
============== ==============
* aprílový easter-egg pro řešitele - vytvořit nějakou vtipnou testovací databázi a nasadit ji místo produkce * aprílový easter-egg pro řešitele - vytvořit nějakou vtipnou testovací databázi a nasadit ji místo produkce
* změnit veškerý text na oranžovo

14
docs/conf.py

@ -85,3 +85,17 @@ source_suffix = {
'.rst': 'restructuredtext', '.rst': 'restructuredtext',
'.md': 'markdown', '.md': 'markdown',
} }
# Autodoc má ignorovat některé moduly
# Ref: https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#event-autodoc-skip-member
# Kudos: https://stackoverflow.com/a/21449475/
def ignorovat(app, what, name, obj, skip, options):
blacklist = (
# typ (what), name
('module', 'settings.mamweb_prod'),
)
ignore = (what, name) in blacklist
return True if ignore else None
def setup(app):
app.connect('autodoc-skip-member', ignorovat)

19
docs/index.rst

@ -6,14 +6,31 @@
Vítejte v dokumentaci M&Mího webu! Vítejte v dokumentaci M&Mího webu!
=================================== ===================================
Tzv. produkce (tedy to, co vidí uživatelé) běží na `<mam.mff.cuni.cz>`_ (resp.
`<mam.matfyz.cz>`_), menu, obrázky v pozadí menu a spousta stránek (ty pouze se
statickým textem/obrázky) se mění přímo na produkci. Testovací verze běží na
`<https://mam-test.ks.matfyz.cz/>`_.
Abychom uměli web vyvíjet, musíme ho většinou nejdřív umět
:doc:`naklonovat a spustit lokálně <vyvoj>`.
:doc:`struktura mamwebu <struktura>` se řídí hlavně djangem, ale snažíme se
také o oddělení jednotlivých částí do :doc:`samostatných aplikací
<modules/modules>`.
Dokumentace (jak v ``docs/``, tak přímo v kódu) je psaná ve
:doc:`sphinxu <sphinx>`.
.. toctree:: .. toctree::
:caption: M&M web :caption: M&M web
:maxdepth: 2 :maxdepth: 2
:titlesonly:
vyvoj vyvoj
sphinx sphinx
dalsi_soubory skripty
modules/modules modules/modules
dalsi_soubory
zapisy/zapisy zapisy/zapisy

108
docs/skripty.rst

@ -0,0 +1,108 @@
Skripty pro práci s repozitářem
===================
Máme dvě hlavní sady skriptů/příkazů na ovládání webu a repozitáře. Skripty pro
práci s webem psané v Pythonu jsou uložené ve složkách
``<aplikace>/management/commands/``, případně vestavěné, a volají se pomocí
``./manage.py <příkaz>``. Oproti tomu skripty pro práci s repozitářem a pro
úpravy databáze a souborů „zvenčí“ se nejčastěji nacházejí ve složce ``make/``
a volají se pomocí cesty: ``make/<příkaz>``.
Občas existují i nějaké další skripty na různých jiných místech. Všechny by
měly být ideálně popsány asi tady.
Make skripty
----
Skripty v ``make/`` se označují jako „Make skripty“. Slouží často k velkým
úkonům s repozitářem, jako je nasazení celého webu, zprovoznění lokálního webu
a podobně.
.. note:: Označení pro tyto skripty je dáno tím, že byly původně volány pomocí
make (tj. z Makefile). Ve skutečnosti je lze stále volat i jako ``make
<skript>``, ale pak není možné předávat parametry a obecně je tato cesta
zastaralá a existuje jen pro zpětnou kompatibilitu se svalovou pamětí.
Tyto skripty jsou samonosné, dají se spustit rovnou a v případě problémů si
budou hlasitě stěžovat. Pro účely debugování různých věcí jsou ale (bohužel?)
hlasité i při normálním spuštění, konkrétně vypisují všechny příkazy, které se
spouštějí (\ ``set -x``). Tyto příkazy jsou vidět za jedním či více plusky (\ ``+``).
.. tip:: Pokud některý make skript selže, tak by na konci měl vypsat, že se něco nepovedlo.
Knihovna ``make/lib.sh``
^^^^^^
Pro pohodlí při psaní velká část z nich využívá knihovnu uloženou
v ``make/lib.sh``. Jsou zde definované užitečné proměnné, kontroly a společný
kód. Kromě toho při inicializaci otestuje, že je skript spuštěn z kořene
repozitáře (takže to pak není potřeba zkoumat v ostatních skriptech).
Proměnné
"""""
Popsány jsou jen užitečné proměnné, ve skutečnosti jich je definovaných víc,
ale jsou triviální a samopopisné.
``VENV_PATH``
Cesta virtuálního prostředí. Též lze přepsat.
``REPO``
Cesta ke gitovému repozitáři na serveru, rovnou použitelná v ``git clone``
``GIMLI_LOGIN``
Přihlašovací údaje ke Gimlimu
``PRODWEB`` a ``TESTWEB``
Cesty ke složkám s produkčním a testovacím webem
Funkce a další zkratky
""""""
``ensure_venv``
Zajistí, že se zbytek skriptu spustí ve virtuálním prostředí, a pokud neexistuje, tak jej založí.
``ensure_web_installed``
Vyzkouší, že je web (django) aspoň elementárně zprovozněno a pokud ne, tak vyzve uživatele, aby to spravil.
``gimli_only``
Otestuje, že je příkaz spuštěn na Gimlim, pokud tomu tak není, zeptá se, jestli si uživatel skutečně přeje zbytek skriptu vykonat
``only_in_directory <složka>``
Otestuje, že skript běží z konkrétní složky. Zejména použitelné s ``gimli_only`` a ``$TESTWEB``
``safe_checkout_branch <větev>``
Bezpečně přepne repozitář na jinou větev. Pokud by mělo dojít k přepsání
knihovny nebo volajícího make skriptu, vyzve uživatele, aby přepnul ručně.
``install_everything``
Společná část kódu pro nasazování produkce a testwebu.
Skripty pro lokální vývoj
^^^^^^^
``make/install_web`` (nebo ekvivalentně ``make/install``)
Vytvoří virtualenv a nainstaluje do něj závislosti webu podle ``requirements.txt``. Následně popíše, jak vyrobit zbytek lokálního webu.
``make/run``
Spustí lokální web (ekvivalentní s ``./manage.py runserver``)
``make/schema``
Vykreslí závislosti a atributy modelů
``make/sync_prod_flatpages``
Stáhne z produkce aktuální statické stránky a uloží je do složky ``data/``
``make/test``
Spustí testy (ekvivalentní s ``./manage.py test -v2``)
``make/init_local``
Zkratka za posloupnost ``make/install_web``, ``./manage.py testdata``, ``./manage.py loaddata data/*``, ``make/sync_prod_flatpages``
Práce s testwebem
^^^^^^^
``make/deploy``
Nasadí testweb. Volitelně bere jako parametr jméno větve, kterou má nasadit.
Rovnou nastaví přihlašování a vygeneruje příslušnou verzi dokumentace `sem <https://mam-test.ks.matfyz.cz/docs>`_.
``make/push_compiled_vue_to_test``
**Neotestováno** Nahraje Vue z lokálního počítače na testweb. (Gimli často má moc starou verzi Node.js, takže nejde zkompilovat tam)
``make/sync_test_db_aggressive``
Zkopíruje databázi z produkčního webu.
``make/sync_test_media``
Zkopíruje média (obrázky, nahrané soubory) z produkčního webu.
``make/sync_test``
Zkratka za ``make/sync_test_db_aggressive`` + ``make/sync_test_media``.
Nasazení produkce
^^^^
``make/deploy_prod``. Před samotným nasazením zálohuje databázi a zkontroluje, že se nasazuje větev ``master``.

4
docs/sphinx.rst

@ -8,12 +8,14 @@ Jinak všechny rst, co jsou ve složce ``doc`` a jejích podsložkách nezačín
Sphinx se píše v rst: `Návod na syntaxi rst`_ `Cheat sheet`_ Sphinx se píše v rst: `Návod na syntaxi rst`_ `Cheat sheet`_
To je snad vše, co je potřeba vědět k dokumentaci mamwebu. Následující sekce jsou o tom, co jsem provedl Sphinxu, aby to fungovalo:
.. _Návod na syntaxi rst: https://sphinx-tutorial.readthedocs.io/step-1/#sections .. _Návod na syntaxi rst: https://sphinx-tutorial.readthedocs.io/step-1/#sections
.. _Cheat sheet: https://sphinx-tutorial.readthedocs.io/cheatsheet/ .. _Cheat sheet: https://sphinx-tutorial.readthedocs.io/cheatsheet/
make html make html
--------- ---------
Make html dělá následující: Vygenerují se rst soubory do modules z pythoní dokumentace pomocí:: ``make html`` dělá následující: Vygenerují se rst soubory do modules z pythoní dokumentace pomocí::
sphinx-apidoc --module-first -o modules .. ../*/migrations --templatedir _templates -f sphinx-apidoc --module-first -o modules .. ../*/migrations --templatedir _templates -f

31
docs/struktura.rst

@ -0,0 +1,31 @@
Co kde najít (mamweb + django)
==============================
Nejdůležitější aplikace z pohledu djanga je ``mamweb``. Tu totiž django pouští
a obsahuje tedy nastavení (tam se přidávají ostatní aplikace, včetně těch
importovaných z djanga, a nastavují se tam různé věci jak v djangu, tak i naše,
například složky, kam se budou věci přidané uživateli ukládat). Dále obsahuje
základní urls, udávající, „na jaké adrese co je“. A nakonec obsahuje obecné
věci jako chybové hlášky a vzhled M&M stránek (menu, patička, atd.). Aktuálně
i veškeré csv.
Další jsou pak jednotlivé aplikace (pokud něco hledáte, tak zřejmě chcete najít
tu aplikaci, která tomu odpovídá, respektive se k ní dostat přes url), za
zmínku stojí seminar, kde jsou takové ty věci, co zbyly. Plus jsou tam aktuálně
téměř všechny modely, protože je těžké je přesunout jinam.
**TLDR: Nevšímejte si složky data/ a souborů přímo v kořenové složce.**
Kromě věcí potřebných ke gitu, :doc:`ke spuštění <vyvoj>` a fukci djanga,
dalších drobností, lokální databáze a již zmíněných aplikací jsou tu ``data``,
kde je takový ten obsah webu, co by se měl dát snadno měnit (tudíž musí být v
databázi), tj. statické stránky, menu a obrázky v pozadí menu. Ten je třeba
měnit hlavně na produkci a sekundárně tady (může to dělat i newebař a nechcete
přepsat jeho práci). Vše, co nejsou aplikace je popsáno :doc:`tady <dalsi_soubory>`.
Základy djanga
--------------
mamweb je psaný téměř čistě v djangu. Což znamená, že to „co je vidět na stránkách“
jsou views.

25
docs/tabulka_prerekvizit.rst

@ -0,0 +1,25 @@
.. Není odkázaná z menu, je to záměr
Tabulka prerekvizit v různých distribucích
=========
.. admonition:: Metodika
Na čistém repozitáři (``git clean -fxd``) a čistém systému spouštíme
``make/init_local``. Když to spadne, tak do tabulky zapíšeme, co jsme
přiinstalovali. Protože větev ``makefiles`` aktuálně není mergenutá do
masteru, nefunguje synchronizace flatpages (a stejně nemáme SSH klíč), takže
tam ``make/init_local`` sestřelíme a vyzkoušíme, že ``make/test`` spustí
testy.
.. Grafické tabulky (grid-tables, simple-tables) jsou strašný porod vyrábět, dlabu na to a cpu to do CSV…
.. csv-table:: Prerekvizity v jednotlivých distribucích
:header: Distribuce / OS, Repozitář s Py3.9, venv, py knihovny, PostgreSQL knihovna, poznámky
Ubuntu 22.10, ??, ``python3-venv``, ``python3-dev``, ``libpq-dev``, "Je potřeba zapnout zdroj ``universe`` a nainstalovat kompilátor C (``gcc``)?"
Linux Mint 21, ??, ``python3-venv``, ``python3-dev``, ``libpq-dev``, ""
Archlinux 2022.11.01, AUR, vestavěný, vestavěné, ``postgresql-libs``, "Je potřeba céčkový kompilátor (``gcc``)"
openSUSE Leap 15.4, oficiální (``python39``), předinstalovaný?, ``python39-devel``, ??FIXME!!, "Výchozí verze pythonu je 3.6 a ta je moc stará, potřeba instalovat ``gcc``. Nevím jak sehnat pg_config."
Debian 11, "oficiální, výchozí", ??, ??, ??, "Určitě to tam rozběhat jde, protože Gimli. Nejspíš bude relativně podobné Ubuntu."

200
docs/vyvoj.rst

@ -1,20 +1,184 @@
Lokální vývoj mamwebu Lokální vývoj mamwebu
===================== =====================
Stačí spustit::
Asi hlavní část vývoje většiny webu probíhá lokálně. Každý tak může pracovat na
## Nahradte svym gimli username vlastních úpravách nezávisle na ostatních.
git clone USER@gimli.ms.mff.cuni.cz:/akce/mam/git/mamweb.git mamweb
cd mamweb Potřebné vybavení
## Instalace je trochu magická, spusť následující posloupnost příkazů: -------
make install_venv
. env/bin/activate Tento soupis cílí na Linuxáky. Jistě je
make install_web spousta dalších možností, které zde nejsou postihnuty – poraď se s webaři,
pokud si nejsi jistý. (Speciálně lze nějak vyvíjet na Windows, leč lze často
## Vygeneruje nejaka testovaci data (spis chuda) narazit na odlišné chování od Linuxu.)
./manage.py testdata
## Nahraje statické stránky, menu a obrázky v pozadí menu Motivace cílení na Linux je to, že Gimli je Linuxový stroj, takže je vývojové
./manage.py loaddata data/* prostředí pak podobné produkci a zmenšuje to množství odlišného chování.
## Spusti testovaci server na http://127.0.0.1:8000/
./manage.py runserver .. TODO: Na dokumentaci odlišného chování (Postgres vs. SQLite, Win vs. Linux, …)
by to asi chtělo výhledově separátní stránku, ale teď píšu tuhle :-)
Když si lokálně spustíte web, běží na http://127.0.0.1:8000/, admin najdete na http://127.0.0.1:8000/admin/ (admin/admin) Až skončíš s vývojem webu, spusť “deactivate”. Tím zmizí ‘(env)’ ze začátku promptu.
Nutné
^^^^
- `Git <https://git-scm.com>`_
- `Python <https://python.org>`_
- Ideálně ve verzi 3.9 (to je to, co je aktuálně (2022) na Gimlim)
- Včetně pip-u (na Ubuntu balíček ``python3-pip``), venvu (``python3-venv``) a knihoven pro kompilaci
Cčkových rozšíření (``python3-dev``)
- Knihovna pro práci s PostgreSQL (``libpq-dev``, protože jsou potřeba všechny db backendy)
- Webový prohlížeč
- \*NIXový shell (typicky ``bash``)
.. TODO: Pokud tu něco chybí, tak to dopiš :-)
Kromě toho je potřeba mít účet na `Gitee <https://gitea.ks.matfyz.cz>`_, kde
bydlí gitový repozitář s kódem.
.. tip:: Potřebné balíčky v různých distribucích jsou sepsané v :ref:`tabulce
prerekvizit <Tabulka prerekvizit v různých distribucích>`.
Doporučené
^^^^^^^^^^
- Python wheel (možná řeší problémy s potřebou ``-dev`` balíčků…)
- Editor / IDE podporující `Editorconfig <https://editorconfig.org/>`_
- Uživatelská zkušenost s `produkční verzí webu <https://mam.matfyz.cz>`_
- Účet v `Kanci <https://kanboard.ledoian.cz>`_
.. TODO: A nejspíš další věci, na které jsem si teď nevzpomněl.
Zprovoznění
-------
Nejprve je potřeba stáhnout si repozitář. To se provede příkazem ``git clone
https://gitea.ks.matfyz.cz/mam/mamweb.git``, případně ``git clone
git@gitea.ks.matfyz.cz:mam/mamweb.git``, pokud už máš nahraný SSH klíč na
Giteu. (Obě adresy se dají zkopírovat ze `stránky repozitáře
<https://gitea.ks.matfyz.cz/mam/mamweb>`_.) To vyrobí složku ``mamweb``, přepni
se do ní (``cd mamweb``)
O zprovoznění webu se stará skript ``make/install_web``, stačí ho spustit. Ten
vytvoří virtualenv (neexistuje-li) a nainstaluje do něj závislosti webu (podle
souboru ``requirements.txt``).
.. FIXME: Novowebaři zmínka o requirements.txt tady moc nepomůže, to má být na
stránce o celkové stavbě repozitáře a stacku…
Následně je potřeba nahrát další data do databáze, což uděláš pomocí příkazů
``./manage.py testdata`` a ``./manage.py loaddata data/*``. Skript
``make/install_web`` to kdyžtak na konci připomene.
.. caution:: Zatímco skripty v ``make/`` to nepotřebují, pro použití skriptu
``./manage.py`` (a dalších) se potřebuješ přepnout do virtuálního prostředí.
To uděláš velmi pravděpodobně spuštěním ``source env/bin/activate``, před
začátkem *promptu* by se mělo objevit ``(env)``. Pro opuštění spusť
``deactivate``.
Samotný web se spustí třeba pomocí ``make/run``, nebo ekvivalentně
``./manage.py runserver`` a pak je vidět na `<http://127.0.0.1:8000>`_.
Časté problémy
^^^^^^
- ``make/install_web`` vypíše ``Error: pg_config executable not found.``:
Chybí ``libpq-dev``
- Chybová hláška obsahuje ``#include <Python.h>``: chybí ``python3-dev``
- Na webu není vidět meníčko: spusť ``./manage.py loaddata data/*``
- ``locale.Error: unsupported locale setting``: Chybí podpora pro příslušný
jazyk ve tvém systému. Odkomentuj příslušnou lokalizaci v ``/etc/locale.gen``
a spusť ``locale-gen`` jako root, tím se to spraví.
S dalšími problémy se zkus obrátit na další webaře, třeba někdo bude vědět :-)
Příkazy pro ovládání webu
-------
Příkazy se dělí do několika skupin. Některé souvisí přímo s webem, Djangem,
databází a podobně, ty typicky používají ``./manage.py``. Skripty pro
obhospodařování repozitáře a webu „zvenku“ typicky bydlí ve složce ``make/``.
Ostatní skripty jsou na náhodných místech :-)
Tady jsou rozebrány jen příkazy relevantní pro lokální web a univerzálně
užitečné, ostatní najdeš v :ref:`Skripty pro práci s repozitářem`.
Make skripty
^^^^^^^
- ``make/install_web`` nainstaluje závislosti webu
- ``make/run`` spustí web (ekvivalentní s ``./manage.py runserver``)
- ``make/schema`` nakreslí schéma vazeb modelů (může se hodit pro referenci a představu)
- ``make/test`` spustí testy (ale moc jich zatím není; ekvivalentní s ``./manage.py test``)
- ``make/sync_prod_flatpages`` stáhne statické stránky z produkčního webu a
uloží je do souboru v gitu, což umožňuje jejich verzování
Manage.py skripty
^^^^^
.. note:: Je potřeba je spouštět ve virtuálním prostředí, viz výše.
Všechny skripty kdyžtak mají ``--help``, dá se tak zjistit, co všechno umějí.
- ``./manage.py testdata`` vygeneruje spíše chudá testovací data, aby bylo na
čem testovat web.
- ``./manage.py loaddata <soubor/y>`` nahraje data ze souborů do databáze
- ``./manage.py dumpdata <model>`` naopak z databáze vyrobí textovou reprezentaci
- ``./manage.py shell`` spustí interaktivní pythoní shell, ze kterého lze
interagovat s webem / Djangem.
- ``./manage.py dbshell`` spustí databázový shell (typicky používá SQL)
- ``./manage.py makemigrations`` vyrobí popis migrací, ``./manage.py migrate``
je spustí, ``showmigrations`` ukáže, které migrace jsou aplikované a které
ne.
- ``./manage.py runserver_plus`` spouští o něco lepší vývojový server (ale
nikdy jsem asi ty lepší featury nepoužil)
Může se hodit vědět, že spuštění ``./manage.py`` bez parametrů vypíše seznam
všech příkazů, které lze spustit.
Dokumentace djangových příkazů je v `dokumentaci Djanga
<https://docs.djangoproject.com/en/3.2/ref/django-admin/#available-commands>`_
Ostatní užitečné příkazy
^^^^^
- ``git status`` je univerzální nápověda na aktuální stav repozitáře a co s tím
lze dělat.
- ``git clean -fxd`` smaže všechny soubory, které nejsou uložené v gitu (včetně
ignorovaných). **Nebezpečný příkaz**, zamysli se, než ho spustíš
Specifika lokálního webu
-------
Lokální uživatelé
^^^^^^^
Přihlašovací údaje jsou psány jako ``login:heslo``
- Superuživatel: ``admin:admin``
- Orgovské účty: ``o:o``, ``o1:o````o3:o``
- Řešitelské účty: ``r:r``, ``r1:r````r3:r``
Všechny tyto účty jsou (?) svázané s nějakými fiktivními osobami, není ale zřejmé se
kterými, budeš to muset vyzkoušet a pak tady zdokumentovat :-)
E-maily
^^^^^
Posílání e-mailů se lokálně dá zkoušet, e-mail se vypíše do terminálu, kde je
web spuštěn (e.g. pomocí ``make/run``).
Pruhy
^^^^
To, že má lokální web po stranách zelené pruhy je normální a správně, slouží to
k vizuálnímu odlišení lokálního webu.
.. TODO: Mít někde popis všech tří instancí a tady na něj pak odkázat.
.. - Tahák k používání gitových větví: do workflow, ne sem…
.. - Užitečné odkazy – kam se kouknout
(dohledávání views podle URL, settings_*, )
.. - Zpříjemnění práce (ssh-klíče, tea, --help, …)

5
docs/zapisy/zapisy.rst

@ -2,4 +2,7 @@ Zápisy
====== ======
.. toctree:: .. toctree::
2021-12-06-testovani_dokumentace_codereview :caption: Importy zápisů z Markdownu
:maxdepth: 1
2021-12-06-testovani_dokumentace_codereview

84
header_fotky/context_processors.py

@ -12,46 +12,46 @@ from header_fotky.models import FotkaUrlVazba
def vzhled(request): def vzhled(request):
""" """
Podle času přidá do contextu, zdali je nebo není noc. Dále podle dení Podle času přidá do contextu, zdali je nebo není noc. Dále podle dení
doby a url přidá do contextu správnou fotku. doby a url přidá do contextu správnou fotku.
url adresu nejprve vyzkouší celou, pak postupně odřezává věci za url adresu nejprve vyzkouší celou, pak postupně odřezává věci za
lomítkem, dokud nenajde url, pro kterou existuje alespoň jedna fotka. lomítkem, dokud nenajde url, pro kterou existuje alespoň jedna fotka.
Z fotek pro toto url zkusí vybrat tu ve správné denní době a poté Z fotek pro toto url zkusí vybrat tu ve správné denní době a poté
libovolnou. (Z více možných fotek pro 1 url a 1 dobu vybírá náhodně.) libovolnou. (Z více možných fotek pro 1 url a 1 dobu vybírá náhodně.)
""" """
hodin = datetime.now().hour hodin = datetime.now().hour
if (hodin <= 6) or (hodin >= 20): if (hodin <= 6) or (hodin >= 20):
noc = True noc = True
nedoba = 'den' nedoba = 'den'
doba = 'noc' doba = 'noc'
else: else:
noc = False noc = False
nedoba = 'noc' nedoba = 'noc'
doba = 'den' doba = 'den'
url = request.path url = request.path
fotky = FotkaUrlVazba.objects.exclude(denni_doba=nedoba) fotky = FotkaUrlVazba.objects.exclude(denni_doba=nedoba)
fotka = None fotka = None
# TODO rychlejší patternmatch? # TODO rychlejší patternmatch?
while (fotka is None) and (url != ''): while (fotka is None) and (url != ''):
presne = fotky.filter(url__exact=url) presne = fotky.filter(url__exact=url)
if presne.count() > 0: if presne.count() > 0:
presne_doba = presne.filter(denni_doba=doba) presne_doba = presne.filter(denni_doba=doba)
if presne_doba.count() > 0: if presne_doba.count() > 0:
fotka = random.choice(presne_doba).url_fotky() fotka = random.choice(presne_doba).url_fotky()
else: else:
fotka = random.choice(presne).url_fotky() fotka = random.choice(presne).url_fotky()
url = url[:-1] url = url[:-1]
index = url.rfind('/') index = url.rfind('/')
if index != -1: if index != -1:
url = url[:index+1] url = url[:index+1]
if fotka is None: if fotka is None:
fotka = settings.STATIC_URL + "images/header/vikendovka.jpg" fotka = settings.STATIC_URL + "images/header/vikendovka.jpg"
return {'noc': noc, 'fotka': fotka} return {'noc': noc, 'fotka': fotka}

18
korektury/migrations/0019_auto_20221205_2014.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2022-12-05 19:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('korektury', '0018_korekturovanepdf_poslat_mail'),
]
operations = [
migrations.AlterField(
model_name='korekturovanepdf',
name='nazev',
field=models.CharField(help_text='Název (např. 22.1 verze 4) korekturovaného PDF', max_length=50, verbose_name='název PDF'),
),
]

2
korektury/models.py

@ -55,7 +55,7 @@ class KorekturovanePDF(models.Model):
cas = models.DateTimeField(u'čas vložení PDF',default=timezone.now,help_text = 'Čas vložení PDF') cas = models.DateTimeField(u'čas vložení PDF',default=timezone.now,help_text = 'Čas vložení PDF')
nazev = models.CharField(u'název PDF',blank = True,max_length=50, help_text='Název (např. 22.1 verze 4) korekturovaného PDF') nazev = models.CharField(u'název PDF',blank = False,max_length=50, help_text='Název (např. 22.1 verze 4) korekturovaného PDF')
komentar = models.TextField(u'komentář k PDF',blank = True, help_text='Komentář ke korekturovanému PDF (např. na co se zaměřit)') komentar = models.TextField(u'komentář k PDF',blank = True, help_text='Komentář ke korekturovanému PDF (např. na co se zaměřit)')

2
korektury/templates/korektury/opraf.html

@ -146,7 +146,7 @@
</button> </button>
{% endif %} {% endif %}
{% if o.status != 'neni_chyba' %} {% if o.status != 'neni_chyba' %}
<button type='submit' name='action' value='wontfix' title='Označ jako irelevantní '> <button type='submit' name='action' value='wontfix' title='Označ, že se nebude měnit'>
<img src="{% static "korektury/imgs/cross.png" %}"/> <img src="{% static "korektury/imgs/cross.png" %}"/>
</button> </button>
{% endif %} {% endif %}

34
korektury/views.py

@ -30,28 +30,28 @@ class KorekturyListView(generic.ListView):
template_name = 'korektury/seznam.html' template_name = 'korektury/seznam.html'
class KorekturyAktualniListView(KorekturyListView): class KorekturyAktualniListView(KorekturyListView):
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
queryset=super().get_queryset() queryset=super().get_queryset()
queryset=queryset.exclude(status="zastarale") queryset=queryset.exclude(status="zastarale")
return queryset return queryset
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['selected'] = 'aktualni' context['selected'] = 'aktualni'
return context return context
class KorekturyZastaraleListView(KorekturyListView): class KorekturyZastaraleListView(KorekturyListView):
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
queryset=super().get_queryset() queryset=super().get_queryset()
queryset=queryset.filter(status="zastarale").order_by("-cas") queryset=queryset.filter(status="zastarale").order_by("-cas")
return queryset return queryset
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['selected'] = 'zastarale' context['selected'] = 'zastarale'
return context return context
class KorekturySeskupeneListView(KorekturyAktualniListView): class KorekturySeskupeneListView(KorekturyAktualniListView):
template_name = 'korektury/seskupeny_seznam.html' template_name = 'korektury/seskupeny_seznam.html'

8
make/README.md

@ -0,0 +1,8 @@
Milý člověče, M&Mí web tě vítá. Prosím, neděs se, zkusím tě provést lokálním zprovozněním webu.
Pro detaily a nápovědu si prosím přečti dokumentaci v docs/vyvoj.rst.
TL;DR: Web vyrobíš pomocí následující posloupnosti příkazů:
make/init_local
make/run
a web potom najdeš na <http://127.0.0.1:8000>

31
make/deploy

@ -0,0 +1,31 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
gimli_only
only_in_directory "$TESTWEB"
CURRENT_BRANCH="$(git branch --show-current)"
BRANCH="${1:-$CURRENT_BRANCH}"
safe_checkout_branch "$BRANCH"
# Teď máme správnou větev, jdeme vše nainstalovat
install_everything
systemctl --user restart mamweb-test.service
# Přihlášení
htpasswd -bc .htpasswd test lisakjelisak
setfacl -m u:www-data:r .htpasswd
# Build dokumentace
ensure_venv
(
cd docs
make html
)
# Oprava práv k dokumentaci
setfacl -m u:www-data:x . docs docs/_build
setfacl -R -m u:www-data:rX docs/_build/html
setfacl -R -m default:u:www-data:rX docs/_build/html

25
make/deploy_prod

@ -0,0 +1,25 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
gimli_only
only_in_directory "$PRODWEB"
CURRENT_BRANCH="$(git branch --show-current)"
BRANCH="${1:-$CURRENT_BRANCH}"
if test "$BRANCH" != master
then
echo "Pozor, nasazuješ na produkci větev, která není master ($BRANCH), chceš pokračovat? Pokud ne, sestřel tento skript."
read
fi
# Záloha DB
( cd -P .. && ./backup_prod_db.sh )
safe_checkout_branch "$BRANCH"
# Teď máme správnou větev, jdeme vše nainstalovat
install_everything
systemctl --user restart mamweb-prod.service

10
make/init_local

@ -0,0 +1,10 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
make/install_web
ensure_venv
./manage.py testdata
./manage.py loaddata data/*
make/sync_prod_flatpages

1
make/install

@ -0,0 +1 @@
install_web

16
make/install_web

@ -0,0 +1,16 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
ensure_venv
# Aktualizace toolchainu
pip install --upgrade pip setuptools
# Instalace závislostí webu
pip install -r requirements.txt --upgrade
# XXX: Připomínka z původního Makefile:
echo 'Pro vygenerování tesdat spusť ./manage.py testdata
Po vygenerování testdat spusť ./manage.py loaddata data/*, ať máš menu a další modely
Pro synchronizaci flatpages spusť make/sync_prod_flatpages'

115
make/lib.sh

@ -0,0 +1,115 @@
#!/bin/false Tohle je knihovna, nemá se spouštět, ale načítat pomocí source(1) nebo '.'
PYTHON="${PYTHON:-python3}"
VENV="${VENV:-${PYTHON} -m venv}"
VENV_PATH="${VENV_PATH:-env}"
BRANCH="${BRANCH:-master}"
REPO="${REPO:-git@gitea.ks.matfyz.cz:mam/mamweb.git}"
UPSTREAM_REMOTE='origin'
GIMLI='gimli.ms.mff.cuni.cz'
GIMLI_LOGIN="mam-web@$GIMLI"
# Skutečné cesty, jak je vrátí `realpath`
PRODWEB="/aux/akce/mam/www/mamweb-prod"
TESTWEB="/aux/akce/mam/www/mamweb-test"
function die {
echo "$@" >&2
exit 1
}
trap 'echo Něco se nepovedlo :-/' ERR
# Vždycky chceme zajistit, že běžíme z rootu repozitáře
# TODO: chceme? Nechceme naopak umět to spouštět odkudkoliv, aspoň u většiny targetů?
test -e '.git' || die "Make skript spuštěn ve špatné složce, spusť ho z kořenového adresáře repozitáře."
function ensure_venv {
test -f "$VENV_PATH/bin/activate" || $VENV "$VENV_PATH"
. "$VENV_PATH/bin/activate"
# To ale není všechno Horste – ten venv nemusí fungovat, chceme to ověřit a případně spadnout.
local SPRAVNA_CESTA="$(readlink -f "$VENV_PATH/bin/python")"
local SKUTECNA_CESTA="$(readlink -f "$(which python)")"
test "$SPRAVNA_CESTA" == "$SKUTECNA_CESTA" || die "Venv asi nefunguje. Prosím smaž si ho a zkus to znovu."
python -c 'print()' > /dev/null || die "Python ve venvu je rozbitý. Prosím smaž venv a zkus to znovu."
}
function ensure_web_installed {
ensure_venv
python -c 'import django; print(django.__version__)' > /dev/null && return
echo >&2 "POZOR: Web nevypadá nainstalovaně, instaluji."
make/install_web
}
function gimli_only {
# Rovnou zkontrolujeme i uživatele
if test "$HOSTNAME" != gimli -o "$USER" != mam-web
then
echo "Tento příkaz se má spouštět na gimlim, chceš pokračovat? Pokud ne, sestřel tento skript."
read
fi
}
function only_in_directory {
local DIR="$1"
local CURRENT="$(readlink -f .)"
if test "$CURRENT" != "$DIR"
then
echo "Tento příkaz se má spouštět ve složce $DIR, chceš pokračovat? Pokud ne, sestřel tento skript."
read
fi
}
function safe_checkout_branch {
if test "$#" -ne 1
then
echo >&2 "Použití: $0 <branch>"
return 1
fi
local BRANCH="$1"
local SCRIPT="$0"
git fetch --all
local UPSTREAM_BRANCH
if git rev-parse "$BRANCH@{u}" >/dev/null 2>/dev/null
then
UPSTREAM_BRANCH="$BRANCH@{u}" # Stačí symbolicky.
else
# Tohle je jediná možná záchrana.
UPSTREAM_BRANCH="$UPSTREAM_REMOTE/$BRANCH"
fi
git rev-parse "$UPSTREAM_BRANCH" || die "Vzdálená větev $UPSTREAM_BRANCH neexistuje?"
# Od teď si musíme dát pozor, abychom nezměnili kód, který právě běží.
# Zkontrolujeme, že se nemění tahle knihovna a skript, který běží.
# `git rev-parse` dává SHA-1 hashe objektů, vizte manuálovou stránku pro pochopení.
# Pozor: tohle porovnává jen verze commitnuté do gitu. Lokální změny udělají něco náhodného…
if test "$(git rev-parse @:make/lib.sh)" != "$(git rev-parse "$UPSTREAM_BRANCH":make/lib.sh)"
then
echo >&2 "Změna v make/lib.sh, prosím pullni manuálně"
exit 1
fi
if test "$(git rev-parse @:"$SCRIPT")" != "$(git rev-parse "$UPSTREAM_BRANCH":"$SCRIPT")"
then
echo >&2 "Změna v $SCRIPT, prosím pullni manuálně"
exit 1
fi
git checkout "$BRANCH"
git pull
git clean -f
}
function install_everything {
# Skoro celé nasazení webu je stejné pro testweb i pro produkci, tak je to tady dohromady
gimli_only
ensure_venv
make/install
./manage.py migrate
./manage.py collectstatic --noinput
setfacl -R -m default:u:www-data:rX media static
setfacl -R -m u:www-data:rX media static
chown -R :mam . || true
chmod -R g+rX,go-w . || true
}

14
make/push_compiled_vue_to_test

@ -0,0 +1,14 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
scp vue_frontend/webpack-stats.json "$GIMLI_LOGIN:$TESTWEB/vue_frontend/"
rsync -ave ssh seminar/static/seminar/vue "$GIMLI_LOGIN:$TESTWEB/seminar/static/seminar/"
ssh "$GIMLI_LOGIN" "
set -euxo pipefail
cd $TESTWEB
. make/lib.sh
ensure_venv
./manage.py collectstatic --noinput
"

8
make/run

@ -0,0 +1,8 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
ensure_web_installed
./manage.py runserver "$@"

9
make/schema

@ -0,0 +1,9 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
ensure_web_installed
./manage.py graph_models seminar | dot -Tpdf > schema_seminar.pdf
./manage.py graph_models -a -g | dot -Tpdf > schema_all.pdf

20
make/sync_prod_flatpages

@ -0,0 +1,20 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
ensure_web_installed
# TODO: This is very ugly, will fix in a future PR (hopefully)
ssh "$GIMLI_LOGIN" "
set -euxo pipefail
cd $PRODWEB
. make/lib.sh
ensure_venv
./manage.py dumpdata flatpages --indent=2 > flat.json
./fix_json.py flat.json flat_fixed.json
"
rsync -ave ssh "$GIMLI_LOGIN:$PRODWEB/flat_fixed.json" data/flat.json
./manage.py loaddata data/flat.json

11
make/sync_test

@ -0,0 +1,11 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
# Prerekvizity nekontrolujeme, dokud voláme další make skripty, tak by akorát
# vedly k víc dotazům na stejnou věc a bylo by to otravné. Pokud tu někdy bude
# něco jiného, tak pak ať tu prerekvizity zmíněné jsou.
make/sync_test_db_aggressive
make/sync_test_media

18
make/sync_test_db_aggressive

@ -0,0 +1,18 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
gimli_only
# Teoreticky není potřeba, ale stejně jinde make skripty nejsou a pouštět to z
# produkce nezní jako běžný stav, kromě toho to aktuálně vyrábí pomocné soubory
# v aktuální složce (FIXME do budoucna) a to na produkci nechceme
only_in_directory "$TESTWEB"
pg_dump mam_test > "dump-test-$(date +"%Y%m%d_%H%M").sql"
pg_dump -Fc mam_prod > dump-prod.sql
psql mam_test -c 'DROP OWNED BY "mam-web";'
pg_restore -c --if-exists -d mam_test dump-prod.sql
rm dump-prod.sql
psql mam_test -c "UPDATE django_site SET name='MaMweb (test)', domain='mam-test.ks.matfyz.cz' WHERE id=1"

9
make/sync_test_media

@ -0,0 +1,9 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
gimli_only
only_in_directory "$TESTWEB"
rsync -av --delete "$PRODWEB/media/" ./media

9
make/test

@ -0,0 +1,9 @@
#!/bin/bash
set -exuo pipefail
. make/lib.sh
ensure_web_installed
trap - ERR # Testy nejspíš selžou, ale nechceme kolem toho dělat další chybovou hlášku.
./manage.py test -v2 --keepdb "$@"

32
mamweb/admin.py

@ -17,14 +17,14 @@ from ckeditor_uploader.widgets import CKEditorUploadingWidget
class FlatpageForm(FlatpageFormOld): class FlatpageForm(FlatpageFormOld):
content = forms.CharField(widget=CKEditorUploadingWidget()) content = forms.CharField(widget=CKEditorUploadingWidget())
class Meta: class Meta:
model = FlatPage # this is not automatically inherited from FlatpageFormOld model = FlatPage # this is not automatically inherited from FlatpageFormOld
exclude = [] exclude = []
class FlatPageAdmin(FlatPageAdminOld): class FlatPageAdmin(FlatPageAdminOld):
form = FlatpageForm form = FlatpageForm
# We have to unregister the normal admin, and then reregister ours # We have to unregister the normal admin, and then reregister ours
@ -36,19 +36,19 @@ locale.setlocale(locale.LC_COLLATE, 'cs_CZ.UTF-8')
# https://books.agiliq.com/projects/django-admin-cookbook/en/latest/set_ordering.html # https://books.agiliq.com/projects/django-admin-cookbook/en/latest/set_ordering.html
# FIXME zpraseno pomocí toho, že Python umí bez problému přepisovat funkce # FIXME zpraseno pomocí toho, že Python umí bez problému přepisovat funkce
def get_app_list(self, request): def get_app_list(self, request):
""" """
Return a sorted list of all the installed apps that have been Return a sorted list of all the installed apps that have been
registered in this site. registered in this site.
""" """
app_dict = self._build_app_dict(request) app_dict = self._build_app_dict(request)
# Sort the apps alphabetically. # Sort the apps alphabetically.
app_list = sorted(app_dict.values(), key=lambda x: locale.strxfrm('!') if (x['name'] == "Seminar") else locale.strxfrm(x['name'].lower())) app_list = sorted(app_dict.values(), key=lambda x: locale.strxfrm('!') if (x['name'] == "Seminar") else locale.strxfrm(x['name'].lower()))
# Sort the models alphabetically within each app. # Sort the models alphabetically within each app.
for app in app_list: for app in app_list:
app['models'].sort(key=lambda x: locale.strxfrm('žž' + x['name'].lower()) if (x['name'].endswith("(Node)")) else locale.strxfrm(x['name'].lower())) app['models'].sort(key=lambda x: locale.strxfrm('žž' + x['name'].lower()) if (x['name'].endswith("(Node)")) else locale.strxfrm(x['name'].lower()))
return app_list return app_list
AdminSite.get_app_list = get_app_list AdminSite.get_app_list = get_app_list

152
mamweb/middleware.py

@ -6,83 +6,83 @@ from django.http import HttpResponse, HttpResponseRedirect
class LoggedInHintCookieMiddleware(object): class LoggedInHintCookieMiddleware(object):
"""Middleware to securely help with 'logged-in' detection for dual HTTP/HTTPS sites. """Middleware to securely help with 'logged-in' detection for dual HTTP/HTTPS sites.
On insecure requests: Checks for a (non-secure) cookie settings.LOGGED_IN_HINT_COOKIE_NAME On insecure requests: Checks for a (non-secure) cookie settings.LOGGED_IN_HINT_COOKIE_NAME
and if present, redirects to HTTPS (same adress). and if present, redirects to HTTPS (same adress).
Note this usually breaks non-GET (POST) requests. Note this usually breaks non-GET (POST) requests.
On secure requests: Updates cookie settings.LOGGED_IN_HINT_COOKIE_NAME to reflect On secure requests: Updates cookie settings.LOGGED_IN_HINT_COOKIE_NAME to reflect
whether an user is logged in in the current session (cookie set to 'True' or cleared). whether an user is logged in in the current session (cookie set to 'True' or cleared).
The cookie is set to expire at the same time as the sessionid cookie. The cookie is set to expire at the same time as the sessionid cookie.
By default, LOGGED_IN_HINT_COOKIE_NAME = 'logged_in_hint'. By default, LOGGED_IN_HINT_COOKIE_NAME = 'logged_in_hint'.
""" """
def __init__(self): def __init__(self):
if hasattr(settings, 'LOGGED_IN_HINT_COOKIE_NAME'): if hasattr(settings, 'LOGGED_IN_HINT_COOKIE_NAME'):
self.cookie_name = settings.LOGGED_IN_HINT_COOKIE_NAME self.cookie_name = settings.LOGGED_IN_HINT_COOKIE_NAME
else: self.cookie_name = 'logged_in_hint' else: self.cookie_name = 'logged_in_hint'
self.cookie_value = 'True' self.cookie_value = 'True'
def cookie_correct(self, request): def cookie_correct(self, request):
return self.cookie_name in request.COOKIES and request.COOKIES[self.cookie_name] == self.cookie_value return self.cookie_name in request.COOKIES and request.COOKIES[self.cookie_name] == self.cookie_value
def process_request(self, request): def process_request(self, request):
if not request.is_secure(): if not request.is_secure():
if self.cookie_correct(request): if self.cookie_correct(request):
# redirect insecure (assuming http) requests with hint cookie to https # redirect insecure (assuming http) requests with hint cookie to https
url = request.build_absolute_uri() url = request.build_absolute_uri()
assert url[:5] == 'http:' assert url[:5] == 'http:'
return HttpResponseRedirect('https:' + url[5:]) return HttpResponseRedirect('https:' + url[5:])
return None return None
def process_response(self, request, response): def process_response(self, request, response):
if request.is_secure(): if request.is_secure():
# assuming full session info (as the conn. is secure) # assuming full session info (as the conn. is secure)
try: try:
user = request.user user = request.user
except AttributeError: # no user - ajax or other special request except AttributeError: # no user - ajax or other special request
return response return response
if user.is_authenticated(): if user.is_authenticated():
if not self.cookie_correct(request): if not self.cookie_correct(request):
expiry = None if request.session.get_expire_at_browser_close() else request.session.get_expiry_date() expiry = None if request.session.get_expire_at_browser_close() else request.session.get_expiry_date()
response.set_cookie(self.cookie_name, value=self.cookie_value, expires=expiry, secure=False) response.set_cookie(self.cookie_name, value=self.cookie_value, expires=expiry, secure=False)
else: else:
if self.cookie_name in request.COOKIES: if self.cookie_name in request.COOKIES:
response.delete_cookie(self.cookie_name) response.delete_cookie(self.cookie_name)
return response return response
class vzhled: class vzhled:
def process_request(self, request): def process_request(self, request):
return None return None
def process_view(self, request, view_func, view_args, view_kwargs): def process_view(self, request, view_func, view_args, view_kwargs):
#print "====== process_request ======" #print "====== process_request ======"
#print view_func #print view_func
#print view_args #print view_args
#print view_kwargs #print view_kwargs
#print "=============================" #print "============================="
return None return None
def process_template_response(self, request, response): def process_template_response(self, request, response):
hodin = datetime.now().hour hodin = datetime.now().hour
if (hodin <= 6) or (hodin >= 14): # TODO 20 if (hodin <= 6) or (hodin >= 14): # TODO 20
response.context_data['noc'] = True response.context_data['noc'] = True
else: else:
response.context_data['noc'] = False response.context_data['noc'] = False
return response return response
def process_response(self, request, response): def process_response(self, request, response):
#hodin = datetime.now().hour #hodin = datetime.now().hour
#if (hodin <= 6) or (hodin >= 14): # TODO 20 #if (hodin <= 6) or (hodin >= 14): # TODO 20
#response.context_data['noc'] = True #response.context_data['noc'] = True
#else: #else:
#response.context_data['noc'] = False #response.context_data['noc'] = False
return response return response
##def process_exception(request, exception): ##def process_exception(request, exception):
#pass #pass

386
mamweb/settings_common.py

@ -40,8 +40,8 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
) )
# Where redirect for login required services # Where redirect for login required services
@ -57,41 +57,41 @@ DOBA_ODHLASENI_PRI_ZASKRTNUTI_NEODHLASOVAT = 365 * 24 * 3600 # rok
# Modules configuration # Modules configuration
AUTHENTICATION_BACKENDS = ( AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', 'django.contrib.auth.backends.ModelBackend',
) )
MIDDLEWARE = ( MIDDLEWARE = (
# 'reversion.middleware.RevisionMiddleware', # 'reversion.middleware.RevisionMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
# FIXME: rozbilo se při přechodu na Django 2.0, nevím, jestli # FIXME: rozbilo se při přechodu na Django 2.0, nevím, jestli
# se to dá zahodit bez náhrady # se to dá zahodit bez náhrady
# 'mamweb.middleware.LoggedInHintCookieMiddleware', # 'mamweb.middleware.LoggedInHintCookieMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware', 'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware',
) )
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': ( 'context_processors': (
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'sekizai.context_processors.sekizai', 'sekizai.context_processors.sekizai',
'header_fotky.context_processors.vzhled', 'header_fotky.context_processors.vzhled',
'various.context_processors.rozliseni', 'various.context_processors.rozliseni',
'various.context_processors.april', 'various.context_processors.april',
) )
}, },
}, },
] ]
@ -99,59 +99,59 @@ TEMPLATES = [
INSTALLED_APPS = ( INSTALLED_APPS = (
# Basic # Basic
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.sites', 'django.contrib.sites',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.auth', 'django.contrib.auth',
# Utilities # Utilities
'sekizai', 'sekizai',
'reversion', 'reversion',
'django_countries', 'django_countries',
'solo', 'solo',
'ckeditor', 'ckeditor',
'ckeditor_uploader', 'ckeditor_uploader',
'taggit', 'taggit',
'dal', 'dal',
'dal_select2', 'dal_select2',
'crispy_forms', 'crispy_forms',
'django_comments', 'django_comments',
'django.contrib.flatpages', 'django.contrib.flatpages',
'django.contrib.humanize', 'django.contrib.humanize',
'sitetree', 'sitetree',
'imagekit', 'imagekit',
'polymorphic', 'polymorphic',
'webpack_loader', 'webpack_loader',
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
# MaMweb # MaMweb
'mamweb', 'mamweb',
'seminar', 'seminar',
'galerie', 'galerie',
'korektury', 'korektury',
'prednasky', 'prednasky',
'header_fotky', 'header_fotky',
'various', 'various',
'various.autentizace', 'various.autentizace',
'api', 'api',
'aesop', 'aesop',
'odevzdavatko', 'odevzdavatko',
'vysledkovky', 'vysledkovky',
'personalni', 'personalni',
'soustredeni', 'soustredeni',
'treenode', 'treenode',
# Admin upravy: # Admin upravy:
# 'material', # 'material',
# 'material.admin', # 'material.admin',
@ -159,76 +159,76 @@ INSTALLED_APPS = (
# 'admin_tools.theming', # 'admin_tools.theming',
# 'admin_tools.menu', # 'admin_tools.menu',
# 'admin_tools.dashboard', # 'admin_tools.dashboard',
'django.contrib.admin', 'django.contrib.admin',
# Nechat na konci (INSTALLED_APPS je uspořádané): # Nechat na konci (INSTALLED_APPS je uspořádané):
'django_cleanup.apps.CleanupConfig', # Uklízí media/ 'django_cleanup.apps.CleanupConfig', # Uklízí media/
) )
DEBUG_TOOLBAR_CONFIG = { DEBUG_TOOLBAR_CONFIG = {
'SHOW_COLLAPSED': True, 'SHOW_COLLAPSED': True,
} }
SUMMERNOTE_CONFIG = { SUMMERNOTE_CONFIG = {
'iframe': False, 'iframe': False,
'airMode': False, 'airMode': False,
'attachment_require_authentication': True, 'attachment_require_authentication': True,
'width': '80%', 'width': '80%',
# 'height': '30em', # 'height': '30em',
'toolbar': [ 'toolbar': [
['style', ['style']], ['style', ['style']],
['font', ['bold', 'italic', 'superscript', 'subscript', 'clear']], ['font', ['bold', 'italic', 'superscript', 'subscript', 'clear']],
['color', ['color']], ['color', ['color']],
['para', ['ul', 'ol', 'paragraph']], ['para', ['ul', 'ol', 'paragraph']],
['table', ['table']], ['table', ['table']],
['insert', ['link', 'picture', 'hr']], ['insert', ['link', 'picture', 'hr']],
['view', ['fullscreen', 'codeview']], ['view', ['fullscreen', 'codeview']],
['help', ['help']], ['help', ['help']],
] ]
} }
CKEDITOR_UPLOAD_PATH = "uploads/" CKEDITOR_UPLOAD_PATH = "uploads/"
CKEDITOR_IMAGE_BACKEND = 'pillow' CKEDITOR_IMAGE_BACKEND = 'pillow'
#CKEDITOR_JQUERY_URL = '//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js' #CKEDITOR_JQUERY_URL = '//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js'
CKEDITOR_CONFIGS = { CKEDITOR_CONFIGS = {
'default': { 'default': {
'entities': False, 'entities': False,
'toolbar': [ 'toolbar': [
['Source', 'ShowBlocks', '-', 'Maximize'], ['Source', 'ShowBlocks', '-', 'Maximize'],
['Bold', 'Italic', 'Subscript', 'Superscript', '-', 'RemoveFormat'], ['Bold', 'Italic', 'Subscript', 'Superscript', '-', 'RemoveFormat'],
['NumberedList','BulletedList','-','Blockquote','-','JustifyLeft','JustifyCenter','JustifyRight','JustifyBlock'], ['NumberedList','BulletedList','-','Blockquote','-','JustifyLeft','JustifyCenter','JustifyRight','JustifyBlock'],
['Link', 'Unlink', 'Anchor', '-', 'Image', 'Table', 'HorizontalRule'], ['Link', 'Unlink', 'Anchor', '-', 'Image', 'Table', 'HorizontalRule'],
['Format'], ['Format'],
], ],
# 'toolbar': 'full', # 'toolbar': 'full',
'height': '40em', 'height': '40em',
'width': '100%', 'width': '100%',
'toolbarStartupExpanded': False, 'toolbarStartupExpanded': False,
'allowedContent' : True, 'allowedContent' : True,
}, },
} }
# Webpack loader # Webpack loader
VUE_FRONTEND_DIR = os.path.join(BASE_DIR, 'vue_frontend') VUE_FRONTEND_DIR = os.path.join(BASE_DIR, 'vue_frontend')
WEBPACK_LOADER = { WEBPACK_LOADER = {
'DEFAULT': { 'DEFAULT': {
'CACHE': False, 'CACHE': False,
'BUNDLE_DIR_NAME': 'vue/', # must end with slash 'BUNDLE_DIR_NAME': 'vue/', # must end with slash
'STATS_FILE': os.path.join(VUE_FRONTEND_DIR, 'webpack-stats.json'), 'STATS_FILE': os.path.join(VUE_FRONTEND_DIR, 'webpack-stats.json'),
'POLL_INTERVAL': 0.1, 'POLL_INTERVAL': 0.1,
'TIMEOUT': None, 'TIMEOUT': None,
'IGNORE': [r'.+\.hot-update.js', r'.+\.map'] 'IGNORE': [r'.+\.hot-update.js', r'.+\.map']
} }
} }
# Dajngo REST Framework # Dajngo REST Framework
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 100 'PAGE_SIZE': 100
} }
@ -236,22 +236,22 @@ REST_FRAMEWORK = {
# Create file 'django.secret' in every install (it is not kept in git) # Create file 'django.secret' in every install (it is not kept in git)
try: try:
with open(os.path.join(os.path.dirname(__file__), '..', 'django.secret')) as f: with open(os.path.join(os.path.dirname(__file__), '..', 'django.secret')) as f:
SECRET_KEY = f.readline().strip() SECRET_KEY = f.readline().strip()
except: except:
SECRET_KEY = '12345zmr_k53a*@f4q_+ji^o@!pgpef*5&8c7zzdqwkdlkj' SECRET_KEY = '12345zmr_k53a*@f4q_+ji^o@!pgpef*5&8c7zzdqwkdlkj'
# Logging # Logging
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
'disable_existing_loggers': False, 'disable_existing_loggers': False,
'formatters': { 'formatters': {
'verbose': { 'verbose': {
'format': '%(levelname)s %(asctime)s %(module)s (logger %(name)s): %(message)s' 'format': '%(levelname)s %(asctime)s %(module)s (logger %(name)s): %(message)s'
}, },
}, },
'filters': { 'filters': {
'Http404AsInfo': { 'Http404AsInfo': {
@ -262,76 +262,76 @@ LOGGING = {
}, },
}, },
'loggers': { 'loggers': {
'django': { 'django': {
'handlers': ['console'], 'handlers': ['console'],
'level': 'DEBUG', 'level': 'DEBUG',
'filters': ['StripSensitiveFormData'], 'filters': ['StripSensitiveFormData'],
}, },
'django.security.csrf': { 'django.security.csrf': {
'handlers': ['console'], 'handlers': ['console'],
'level': 'DEBUG', 'level': 'DEBUG',
'filters': ['StripSensitiveFormData'], 'filters': ['StripSensitiveFormData'],
}, },
'django.request': { 'django.request': {
'handlers': ['console'], 'handlers': ['console'],
'level': 'DEBUG', 'level': 'DEBUG',
'filters': ['Http404AsInfo'], 'filters': ['Http404AsInfo'],
}, },
'seminar.prihlaska.form':{ 'seminar.prihlaska.form':{
'handlers': ['console','registration_logfile'], 'handlers': ['console','registration_logfile'],
'level': 'INFO' 'level': 'INFO'
}, },
'seminar.prihlaska.problem':{ 'seminar.prihlaska.problem':{
'handlers': ['console','mail_registration','registration_error_log'], 'handlers': ['console','mail_registration','registration_error_log'],
'level': 'INFO' 'level': 'INFO'
}, },
# Catch-all logger # Catch-all logger
'': { '': {
'handlers': ['console'], # Add 'mail_admins' in prod and test 'handlers': ['console'], # Add 'mail_admins' in prod and test
'level': 'DEBUG', 'level': 'DEBUG',
'filters': ['StripSensitiveFormData'], 'filters': ['StripSensitiveFormData'],
}, },
}, },
'handlers': { 'handlers': {
'console': { 'console': {
'level': 'WARNING', ## Set to 'DEBUG' in local 'level': 'WARNING', ## Set to 'DEBUG' in local
'class': 'logging.StreamHandler', 'class': 'logging.StreamHandler',
'formatter': 'verbose', 'formatter': 'verbose',
}, },
'mail_admins': { 'mail_admins': {
'level': 'WARNING', 'level': 'WARNING',
'class': 'django.utils.log.AdminEmailHandler', 'class': 'django.utils.log.AdminEmailHandler',
'formatter': 'verbose', 'formatter': 'verbose',
'filters': ['StripSensitiveFormData'], 'filters': ['StripSensitiveFormData'],
}, },
'mail_registration': { 'mail_registration': {
'level': 'WARNING', 'level': 'WARNING',
'class': 'django.utils.log.AdminEmailHandler', 'class': 'django.utils.log.AdminEmailHandler',
'formatter': 'verbose', 'formatter': 'verbose',
}, },
'registration_logfile':{ 'registration_logfile':{
'level': 'INFO', 'level': 'INFO',
'class': 'logging.FileHandler', 'class': 'logging.FileHandler',
# filename declared in specific configuration files # filename declared in specific configuration files
'formatter': 'verbose', 'formatter': 'verbose',
}, },
'registration_error_log':{ 'registration_error_log':{
'level': 'INFO', 'level': 'INFO',
'class': 'logging.FileHandler', 'class': 'logging.FileHandler',
# filename declared in specific configuration files # filename declared in specific configuration files
'formatter': 'verbose', 'formatter': 'verbose',
}, },
}, },
} }
# Permissions for uploads # Permissions for uploads
FILE_UPLOAD_PERMISSIONS = 0o0644 FILE_UPLOAD_PERMISSIONS = 0o0644
@ -352,14 +352,14 @@ POSLI_MAILOVOU_NOTIFIKACI = False
# Logování chyb # Logování chyb
class InvalidTemplateVariable(str): class InvalidTemplateVariable(str):
def __mod__(self, variable): def __mod__(self, variable):
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
for line in traceback.walk_stack(None): for line in traceback.walk_stack(None):
if 'context' in line[0].f_locals and 'request' in line[0].f_locals['context']: if 'context' in line[0].f_locals and 'request' in line[0].f_locals['context']:
logger.warning("Proměnná '%s' neexistuje: %s" % (variable, line[0].f_locals['context']['request'])) logger.warning("Proměnná '%s' neexistuje: %s" % (variable, line[0].f_locals['context']['request']))
break break
return '' return ''
TEMPLATES[0]['OPTIONS']['string_if_invalid'] = InvalidTemplateVariable('%s') TEMPLATES[0]['OPTIONS']['string_if_invalid'] = InvalidTemplateVariable('%s')
# Django 3.2 vyžaduje explicitní nastavení autoklíče, zatím nechápu proč # Django 3.2 vyžaduje explicitní nastavení autoklíče, zatím nechápu proč

99
mamweb/settings_local.py

@ -11,16 +11,16 @@ import os.path
from .settings_common import * from .settings_common import *
MIDDLEWARE += ( MIDDLEWARE += (
'debug_toolbar.middleware.DebugToolbarMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware',
) )
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
INSTALLED_APPS += ( INSTALLED_APPS += (
'debug_toolbar', 'debug_toolbar',
'django_extensions', 'django_extensions',
) )
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
@ -37,10 +37,13 @@ ALLOWED_HOSTS.append('localhost')
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases # https://docs.djangoproject.com/en/1.7/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db-local.sqlite3'), 'NAME': os.path.join(BASE_DIR, 'db-local.sqlite3'),
} 'TEST': {
'NAME': os.path.join(BASE_DIR, 'db-test.sqlite3'),
},
},
} }
#DATABASES = { #DATABASES = {
# 'default': { # 'default': {
@ -52,46 +55,46 @@ DATABASES = {
# LOGGING # LOGGING
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
'disable_existing_loggers': True, 'disable_existing_loggers': True,
'filters': { 'filters': {
'require_debug_false': { 'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse' '()': 'django.utils.log.RequireDebugFalse'
} }
}, },
'formatters': { 'formatters': {
'simple': { 'simple': {
'format': '%(asctime)s - %(name)s - %(levelname)-8s - %(message)s', 'format': '%(asctime)s - %(name)s - %(levelname)-8s - %(message)s',
}, },
}, },
'handlers': { 'handlers': {
'dummy': { 'dummy': {
'class': 'logging.NullHandler', 'class': 'logging.NullHandler',
}, },
'console': { 'console': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.StreamHandler', 'class': 'logging.StreamHandler',
'formatter': 'simple', 'formatter': 'simple',
}, },
}, },
'loggers': { 'loggers': {
# Vypisovani databazovych dotazu do konzole # Vypisovani databazovych dotazu do konzole
#'django.db.backends': { #'django.db.backends': {
# 'level': 'DEBUG', # 'level': 'DEBUG',
# 'handlers': ['console'], # 'handlers': ['console'],
# 'propagate': False, # 'propagate': False,
#}, #},
'werkzeug': { 'werkzeug': {
'handlers': ['console'], 'handlers': ['console'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': True, 'propagate': True,
}, },
'': { '': {
'handlers': ['console'], 'handlers': ['console'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False, 'propagate': False,
}, },
}, },
} }
# set to 'DEBUG' for EXTRA verbose output # set to 'DEBUG' for EXTRA verbose output

20
mamweb/settings_prod.py

@ -16,8 +16,8 @@ from .settings_common import *
# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
INSTALLED_APPS += ( INSTALLED_APPS += (
'django_extensions', 'django_extensions',
) )
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
assert not SECRET_KEY.startswith('12345') assert not SECRET_KEY.startswith('12345')
@ -34,14 +34,14 @@ ALLOWED_HOSTS = ['mam.mff.cuni.cz', 'www.mam.mff.cuni.cz', 'atrey.karlin.mff.cun
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases # https://docs.djangoproject.com/en/1.7/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2', 'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'mam_prod', 'NAME': 'mam_prod',
'USER': 'mam-web', 'USER': 'mam-web',
'TEST': { 'TEST': {
'NAME': 'mam-prod-testdb', 'NAME': 'mam-prod-testdb',
}, },
}, },
} }
import os import os

28
mamweb/settings_test.py

@ -13,16 +13,16 @@ import os.path
from .settings_common import * # zatim nutne, casem snad vyresime # noqa from .settings_common import * # zatim nutne, casem snad vyresime # noqa
MIDDLEWARE += ( MIDDLEWARE += (
'debug_toolbar.middleware.DebugToolbarMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware',
) )
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/
INSTALLED_APPS += ( INSTALLED_APPS += (
'debug_toolbar', 'debug_toolbar',
'django_extensions', 'django_extensions',
) )
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = ')^u=i65*zmr_k53a*@f4q_+ji^o@!pgpef*5&8c7zzv9l+zo)n' SECRET_KEY = ')^u=i65*zmr_k53a*@f4q_+ji^o@!pgpef*5&8c7zzv9l+zo)n'
@ -38,21 +38,21 @@ ALLOWED_HOSTS = ['*.mam.mff.cuni.cz', 'atrey.karlin.mff.cuni.cz', 'mam.mff.cuni.
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases # https://docs.djangoproject.com/en/1.7/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2', 'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'mam_test', 'NAME': 'mam_test',
'USER': 'mam-web', 'USER': 'mam-web',
'TEST': { 'TEST': {
'NAME': 'mam-test-testdb', 'NAME': 'mam-test-testdb',
}, },
}, },
} }
import os import os
SERVER_EMAIL = 'mamweb-test-errors@mam.mff.cuni.cz' SERVER_EMAIL = 'mamweb-test-errors@mam.mff.cuni.cz'
ADMINS = [ ADMINS = [
('M&M ERRORs', 'mam-errors@mam.mff.cuni.cz'), ('M&M ERRORs', 'mam-errors@mam.mff.cuni.cz'),
] ]

24
mamweb/static/css/mamweb.css

@ -425,6 +425,13 @@ textarea.feedback {
margin: 5px; margin: 5px;
} }
/* td obsahující křížek v detailu řešení se nesmí smrštit na 0 */
/* FIXME až bude firefox příčetný, nahradit td:has(.smazat_hodnoceni) */
.has_smazat_hodnoceni {
min-width: 20px;
padding: 3px;
}
/* titulni stranka */ /* titulni stranka */
@ -1152,8 +1159,7 @@ div.zadani_termin .datum {
/* posune kotvu obrázku v galerii o oranžový pruh dolu, aby se pod ním obrázek neschovával */ /* posune kotvu obrázku v galerii o oranžový pruh dolu, aby se pod ním obrázek neschovával */
/* https://stackoverflow.com/questions/10732690/offsetting-an-html-anchor-to-adjust-for-fixed-header */ /* https://stackoverflow.com/questions/10732690/offsetting-an-html-anchor-to-adjust-for-fixed-header */
.kotva_obrazku { .kotva_obrazku {
display: block; position: absolute;
position: relative;
width: 0; width: 0;
height: 55px; /* viz #title */ height: 55px; /* viz #title */
margin-top: -55px; /* viz #title */ margin-top: -55px; /* viz #title */
@ -1210,6 +1216,20 @@ div.gdpr {
} }
/* tabulka odevzdaných a došlých řešení */ /* tabulka odevzdaných a došlých řešení */
/* Roztáhne obsah z containeru na celou šířku obrazovky: */
.full_width {
width: 100vw;
margin-left: calc(-50vw + 485px);
}
/* Na úzkém displeji nechceme nic dělat. */
@media(max-width: 860px) {
.full_width{
margin-left: 0;
width: unset;
}
}
.dosla_reseni tr th, .dosla_reseni tr td { .dosla_reseni tr th, .dosla_reseni tr td {
padding: 1px 10px 1px 10px; padding: 1px 10px 1px 10px;
border-collapse: collapse; border-collapse: collapse;

16
odevzdavatko/forms.py

@ -63,7 +63,7 @@ class PosliReseniForm(forms.Form):
class NahrajReseniForm(forms.ModelForm): class NahrajReseniForm(forms.ModelForm):
class Meta: class Meta:
model = m.Reseni model = m.Reseni
fields = ('problem',) fields = ('problem', 'resitele')
help_texts = {'problem':''} # Nezobrazovat help text ve formuláři help_texts = {'problem':''} # Nezobrazovat help text ve formuláři
widgets = {'problem': widgets = {'problem':
@ -72,9 +72,23 @@ class NahrajReseniForm(forms.ModelForm):
attrs = {'data-placeholder--id': '-1', attrs = {'data-placeholder--id': '-1',
'data-placeholder--text' : '---', 'data-placeholder--text' : '---',
'data-allow-clear': 'true'}, 'data-allow-clear': 'true'},
),
'resitele':
autocomplete.ModelSelect2Multiple(
url='autocomplete_resitel_public',
attrs = {'data-placeholder--id': '-1',
'data-placeholder--text' : '---',
'data-allow-clear': 'true'},
) )
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# FIXME Z nějakého důvodu se do této třídy dostaneme i bez resitele
if 'resitele' in self.fields:
# FIXME Mnohem hezčí by to bylo u definice resitele výše, ale nepodařilo se mi to.
self.fields['resitele'].required = False
ReseniSPrilohamiFormSet = inlineformset_factory(m.Reseni,m.PrilohaReseni, ReseniSPrilohamiFormSet = inlineformset_factory(m.Reseni,m.PrilohaReseni,
form = NahrajReseniForm, form = NahrajReseniForm,
fields = ('soubor','res_poznamka'), fields = ('soubor','res_poznamka'),

22
odevzdavatko/static/odevzdavatko/check_for_detail.js

@ -0,0 +1,22 @@
// Kontrola, že org neposílá nějakou blbost v detail.html
function zkontroluj_hodnoceni() {
const pocet = $('.hodnoceni').length;
if (pocet === 1) { // vidím pouze plusko
const vysledek = confirm("Odstranil jsi všechny problémy tohoto řešení. Nepůjde tedy dohledat přes problémy, co řeší, tj. například v došlých řešeních. Přesto odeslat?");
if (!vysledek) {
event.preventDefault();
return false;
}
}
function problem_is_empty(elem, index, array) {return elem.firstElementChild.children.length !== 1 && elem.firstElementChild.children[1].textContent === "";}
if ($('.hodnoceni').toArray().some(problem_is_empty)) {
alert("Neuloženo! Nezadal jsi problém, ke kterému posíláš hodnocení. Pokud je toto hodnocení navíc, smaž ho prosím křížkem a znovu odešli.")
event.preventDefault()
return false;
}
return true;
}

56
odevzdavatko/static/odevzdavatko/dynamic_formsets_for_detail.js

@ -0,0 +1,56 @@
// FIXME: Necopypastovat! Tohle je zkopírované ze static/odevzdavatko/dynamic_formsets.js
// Credit https://medium.com/all-about-django/adding-forms-dynamically-to-a-django-formset-375f1090c2b0
function updateElementIndex(el, prefix, ndx) {
var id_regex = new RegExp('(' + prefix + '-\\d+)');
var replacement = prefix + '-' + ndx;
if ($(el).attr("for")) {
$(el).attr("for", $(el).attr("for").replace(id_regex, replacement));
}
if (el.id) {
el.id = el.id.replace(id_regex, replacement);
}
if (el.name) {
el.name = el.name.replace(id_regex, replacement);
}
}
// Credit https://medium.com/all-about-django/adding-forms-dynamically-to-a-django-formset-375f1090c2b0
function deleteForm(prefix, btn) {
var total = parseInt($('#id_' + prefix + '-TOTAL_FORMS').val());
if (total >= 1){
btn.closest('tr').remove();
var forms = $('.hodnoceni');
var formCount = forms.length - 1; // There is one extra such form hidden as template!
$('#id_' + prefix + '-TOTAL_FORMS').val(formCount);
for (var i=0; i<formCount; i++) {
$(forms.get(i)).find(':input').each(function() {
updateElementIndex(this, prefix, i);
});
}
}
return false;
}
// Credit: https://simpleit.rocks/python/django/dynamic-add-form-with-add-button-in-django-modelformset-template/
$(document).ready(function(){
$('#pridat_hodnoceni').click(function() {
var form_idx = $('#id_form-TOTAL_FORMS').val();
var new_form = $('#empty_form').html().replace(/__prefix__/g, form_idx);
$('#form_set').append(new_form);
// Newly created form has not the binding between remove button and remove function
// We need to add it manually
$('.smazat_hodnoceni').click(function(){
deleteForm("form",this);
});
// Copy deadline
if (form_idx !== "0") {
$('#id_form-' + form_idx + '-deadline_body')[0].value = $('#id_form-' + (form_idx - 1) + '-deadline_body')[0].value
}
$('#id_form-TOTAL_FORMS').val(parseInt(form_idx) + 1);
});
$('.smazat_hodnoceni').click(function(){
deleteForm("form",this);
});
});

120
odevzdavatko/templates/odevzdavatko/detail.html

@ -1,71 +1,32 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load deadliny %} {% load deadliny %}
{% load mail %}
{% block content %} {% block content %}
{# FIXME: Necopypastovat! Tohle je zkopírované ze static/odevzdavatko/dynamic_formsets.js #} {% if edit %}
<script type='text/javascript'> <script src="{% static 'odevzdavatko/dynamic_formsets_for_detail.js' %}"></script>
// Credit https://medium.com/all-about-django/adding-forms-dynamically-to-a-django-formset-375f1090c2b0 <script src="{% static 'odevzdavatko/check_for_detail.js' %}"></script>
function updateElementIndex(el, prefix, ndx) { {% endif %}
var id_regex = new RegExp('(' + prefix + '-\\d+)');
var replacement = prefix + '-' + ndx;
if ($(el).attr("for")) {
$(el).attr("for", $(el).attr("for").replace(id_regex, replacement));
}
if (el.id) {
el.id = el.id.replace(id_regex, replacement);
}
if (el.name) {
el.name = el.name.replace(id_regex, replacement);
}
}
// Credit https://medium.com/all-about-django/adding-forms-dynamically-to-a-django-formset-375f1090c2b0
function deleteForm(prefix, btn) {
var total = parseInt($('#id_' + prefix + '-TOTAL_FORMS').val());
if (total >= 1){
btn.closest('tr').remove();
var forms = $('.hodnoceni');
var formCount = forms.length - 1; // There is one extra such form hidden as template!
$('#id_' + prefix + '-TOTAL_FORMS').val(formCount);
for (var i=0; i<formCount; i++) {
$(forms.get(i)).find(':input').each(function() {
updateElementIndex(this, prefix, i);
});
}
}
return false;
}
// Credit: https://simpleit.rocks/python/django/dynamic-add-form-with-add-button-in-django-modelformset-template/
$(document).ready(function(){
$('#pridat_hodnoceni').click(function() {
var form_idx = $('#id_form-TOTAL_FORMS').val();
var new_form = $('#empty_form').html().replace(/__prefix__/g, form_idx);
$('#form_set').append(new_form);
// Newly created form has not the binding between remove button and remove function
// We need to add it manually
$('.smazat_hodnoceni').click(function(){
deleteForm("form",this);
});
// Copy deadline
if (form_idx !== "0") {
$('#id_form-' + form_idx + '-deadline_body')[0].value = $('#id_form-' + (form_idx - 1) + '-deadline_body')[0].value
}
$('#id_form-TOTAL_FORMS').val(parseInt(form_idx) + 1);
});
$('.smazat_hodnoceni').click(function(){
deleteForm("form",this);
});
});
</script>
<p>Řešené problémy: {{ object.problem.all | join:", " }}</p> <p>Řešené problémy: {{ object.problem.all | join:", " }}</p>
<p>Řešitelé: {% for r in object.resitele.all %} {{ r }} (<a href="mailto:{{ r.osoba.email }}?subject={{ "Oprava řešení M&M " | urlencode }}{{ object.problem.all.0.hlavni_problem | urlencode }}">{{ r.osoba.email }}</a>) {% if edit %}
{% if forloop.revcounter0 != 0 %}, {% endif %} {% endfor %}</p> <p>Řešitelé:
{% for r in object.resitele.all %}
{{ r }}
{# DjangoTemplates neumí spojovat řetězce (https://stackoverflow.com/q/4386168), tak si necháváme vyrobit subject mailu ve view. #}
({% maillink r.osoba.email to=r.osoba.email subject=predmetmailu %}){% if forloop.revcounter0 != 0 %}, {% endif %}
{% endfor %}
</p>
<p>
{% maillink "Poslat mail všem řešitelům" bcc=maily_vsech_resitelu subject=predmetmailu %}
</p>
{% else %}
<p>Řešitelé: {{ object.resitele.all | join:", " }}</p>
{% endif %}
{# https://docs.djangoproject.com/en/3.1/ref/models/instances/#django.db.models.Model.get_FOO_display #} {# https://docs.djangoproject.com/en/3.1/ref/models/instances/#django.db.models.Model.get_FOO_display #}
<p>Forma: {{ object.get_forma_display }}</p> <p>Forma: {{ object.get_forma_display }}</p>
@ -82,13 +43,13 @@ $(document).ready(function(){
<td><a href="{{ priloha.soubor.url }}" download>{{ priloha.split | last }}</a></td> <td><a href="{{ priloha.soubor.url }}" download>{{ priloha.split | last }}</a></td>
<td>{{ priloha.res_poznamka }}</td> <td>{{ priloha.res_poznamka }}</td>
<td>{{ priloha.vytvoreno }}</td></tr> <td>{{ priloha.vytvoreno }}</td></tr>
{# TODO: Orgo-poznámka, ideálně jako formulář #}
{% endfor %} {% endfor %}
</table> </table>
{% else %} {% else %}
<p>Žádné přílohy</p> <p>Žádné přílohy</p>
{% endif %} {% endif %}
{% if edit %}
<form method=post onsubmit="return zkontroluj_hodnoceni();"> <form method=post onsubmit="return zkontroluj_hodnoceni();">
{# Poznámka #} {# Poznámka #}
<h3>Neveřejná poznámka:</h3> <h3>Neveřejná poznámka:</h3>
@ -109,14 +70,14 @@ $(document).ready(function(){
<td>{{ subform.body }}</td> <td>{{ subform.body }}</td>
<td>{{ subform.deadline_body }}</td> <td>{{ subform.deadline_body }}</td>
<td>{{ subform.feedback }}</td> <td>{{ subform.feedback }}</td>
<td><a href="#" class="smazat_hodnoceni" id="id_{{subform.prefix}}-jsremove"><img src="{% static "odevzdavatko/cross.png" %}" alt="Smazat"></a></td> <td class="has_smazat_hodnoceni"><a href="#" class="smazat_hodnoceni" id="id_{{subform.prefix}}-jsremove" title="Smazat hodnocení"><img src="{% static "odevzdavatko/cross.png" %}" alt="Smazat"></a></td>
</tr> </tr>
</tbody> </tbody>
{% endfor %} {% endfor %}
</table> </table>
<a href="#"> <img src="{% static "odevzdavatko/plus.png" %}" id="pridat_hodnoceni" alt="Přidat hodnocení"></a> </br> <a href="#" title="Přidat hodnocení"> <img src="{% static "odevzdavatko/plus.png" %}" id="pridat_hodnoceni" alt="Přidat hodnocení"></a> <br/>
<input type=submit value="Uložit"></form> <input type=submit value="Uložit"></form>
<table id="empty_form" style="display: none;"> <table id="empty_form" style="display: none;">
@ -125,32 +86,23 @@ $(document).ready(function(){
<td>{{ form.empty_form.body }}</td> <td>{{ form.empty_form.body }}</td>
<td>{{ form.empty_form.deadline_body }}</td> <td>{{ form.empty_form.deadline_body }}</td>
<td>{{ form.empty_form.feedback }}</td> <td>{{ form.empty_form.feedback }}</td>
<td><a href="#" class="smazat_hodnoceni" id="id_{{form.empty_form.prefix}}-jsremove"><img src="{% static "odevzdavatko/cross.png" %}" alt="Smazat"></a></td> <td class="has_smazat_hodnoceni"><a href="#" class="smazat_hodnoceni" id="id_{{form.empty_form.prefix}}-jsremove" title="Smazat hodnocení"><img src="{% static "odevzdavatko/cross.png" %}" alt="Smazat"></a></td>
</tr> </tr>
</table> </table>
{% else %}
<h3>Hodnocení:</h3>
<table class="dosla_reseni">
<tr><th>Problém</th><th>Body</th><th>Zpětná vazba od opravovatele</th></tr>
{% for h in hodnoceni %}
<tr class="hodnoceni">
<td>{{ h.problem }}</td>
<td>{{ h.body }}</td>
<td>{{ h.feedback | linebreaks }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
<script type="text/javascript">
function zkontroluj_hodnoceni() {
const pocet = $('.hodnoceni').length;
if (pocet === 1) { {# vydím pouze plusko #}
const vysledek = confirm("Odstranil jsi všechny problémy tohoto řešení. Nepůjde tedy dohledat přes problémy, co řeší, tj. například v došlých řešeních. Přesto odeslat?");
if (!vysledek) {
event.preventDefault();
return false;
}
}
function problem_is_empty(elem, index, array) {return elem.firstElementChild.children.length !== 1 && elem.firstElementChild.children[1].textContent === "";}
if ($('.hodnoceni').toArray().some(problem_is_empty)) {
alert("Neuloženo! Nezadal jsi problém, ke kterému posíláš hodnocení. Pokud je toto hodnocení navíc, smaž ho prosím křížkem a znovu odešli.")
event.preventDefault()
return false;
}
return true;
}
</script>
{% endblock %} {% endblock %}

51
odevzdavatko/templates/odevzdavatko/detail_resitele.html

@ -1,51 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% load deadliny %}
{% block content %}
<p>Řešené problémy: {{ object.problem.all | join:", " }}</p>
<p>Řešitelé: {% for r in object.resitele.all %} {{ r }}
{% if forloop.revcounter0 != 0 %}, {% endif %} {% endfor %}</p>
{# https://docs.djangoproject.com/en/3.1/ref/models/instances/#django.db.models.Model.get_FOO_display #}
<p>Forma: {{ object.get_forma_display }}</p>
<p>Doručeno {{ object.cas_doruceni }}, deadline: {{object.deadline_reseni | deadline_html }}</p>
{# Soubory: #}
<h3>Přílohy:</h3>
{% if object.prilohy.all %}
<table class="dosla_reseni">
<tr><th>Soubor</th><th>Řešitelova poznámka</th><th>Datum</th></tr>
{% for priloha in object.prilohy.all %}
<tr>
<td><a href="{{ priloha.soubor.url }}" download>{{ priloha.split | last }}</a></td>
<td>{{ priloha.res_poznamka }}</td>
<td>{{ priloha.vytvoreno }}</td></tr>
{# TODO: Orgo-poznámka, ideálně jako formulář #}
{% endfor %}
</table>
{% else %}
<p>Žádné přílohy</p>
{% endif %}
{#<h3>Poznámka:</h3>#}
{#<p>{{ poznamka }}</p>#}
{# Hodnocení: #}
<h3>Hodnocení:</h3>
<table id="form_set" class="dosla_reseni">
<tr><th>Problém</th><th>Body</th><th>Zpětná vazba od opravovatele</th>{# <th>Deadline pro body</th> #}</tr>
{% for h in hodnoceni %}
<tr class="hodnoceni">
<td>{{ h.problem }}</td>
<td>{{ h.body }}</td>
<td>{{ h.feedback }}</td>
{# <td>{{ h.deadline_body }}</td>#}
</tr>
{% endfor %}
</table>
{% endblock %}

2
odevzdavatko/templates/odevzdavatko/nahraj_reseni.html

@ -13,6 +13,8 @@
<p style="text-align: justify">Když řešení různých témátek vložíš každé zvlášť, lépe se v nich vyznáme a&nbsp;třeba ti je i&nbsp;rychleji opravíme.</p> <p style="text-align: justify">Když řešení různých témátek vložíš každé zvlášť, lépe se v nich vyznáme a&nbsp;třeba ti je i&nbsp;rychleji opravíme.</p>
<p>Pokud řešíte ve více lidech, je <strong>nutné</strong> přidat tyto lidi jako „Autory řešení“. V tomto poli se vyhledává podle přezdívek, které si lze nastavit v „Osobní údaje“. Sebe vyplňovat nemusíte a za skupinu odevzdávejte pouze <strong>jednou</strong> (ne každý sám).</p>
<form enctype="multipart/form-data" action="{% url 'seminar_nahraj_reseni' %}" method="post" onsubmit="return zkontroluj_prilohy();"> <form enctype="multipart/form-data" action="{% url 'seminar_nahraj_reseni' %}" method="post" onsubmit="return zkontroluj_prilohy();">
{% csrf_token %} {% csrf_token %}
<table class='form' id="reseni"> <table class='form' id="reseni">

2
odevzdavatko/templates/odevzdavatko/tabulka.html

@ -4,6 +4,7 @@
{% block content %} {% block content %}
<div class="full_width">
<form method=get action=.> <form method=get action=.>
{{ filtr.resitele }} {{ filtr.resitele }}
{{ filtr.problemy }} {{ filtr.problemy }}
@ -64,4 +65,5 @@ Do data (včetně): {{ filtr.reseni_do }}
location.assign(redirect); location.assign(redirect);
} }
</script> </script>
</div>
{% endblock %} {% endblock %}

4
odevzdavatko/urls.py

@ -26,9 +26,9 @@ urlpatterns = [
path('org/reseni/', org_required(views.TabulkaOdevzdanychReseniView.as_view()), name='odevzdavatko_tabulka'), path('org/reseni/', org_required(views.TabulkaOdevzdanychReseniView.as_view()), name='odevzdavatko_tabulka'),
path('org/reseni/rocnik/<int:rocnik>/', org_required(views.TabulkaOdevzdanychReseniView.as_view()), name='odevzdavatko_tabulka'), path('org/reseni/rocnik/<int:rocnik>/', org_required(views.TabulkaOdevzdanychReseniView.as_view()), name='odevzdavatko_tabulka'),
path('org/reseni/<int:problem>/<int:resitel>/', org_required(views.ReseniProblemuView.as_view()), name='odevzdavatko_reseni_resitele_k_problemu'), path('org/reseni/<int:problem>/<int:resitel>/', org_required(views.ReseniProblemuView.as_view()), name='odevzdavatko_reseni_resitele_k_problemu'),
path('org/reseni/<int:pk>', org_required(viewMethodSwitch(get=views.DetailReseniView.as_view(), post=views.hodnoceniReseniView)), name='odevzdavatko_detail_reseni'), path('org/reseni/<int:pk>', org_required(viewMethodSwitch(get=views.EditReseniView.as_view(), post=views.hodnoceniReseniView)), name='odevzdavatko_detail_reseni'),
path('org/reseni/all', org_required(views.SeznamReseniView.as_view())), path('org/reseni/all', org_required(views.SeznamReseniView.as_view())),
path('org/reseni/akt', org_required(views.SeznamAktualnichReseniView.as_view())), path('org/reseni/akt', org_required(views.SeznamAktualnichReseniView.as_view())),
path('resitel/reseni/<int:pk>', resitel_or_org_required(views.ResitelReseniView.as_view()), name='odevzdavatko_resitel_reseni'), path('resitel/reseni/<int:pk>', resitel_or_org_required(views.DetailReseniView.as_view()), name='odevzdavatko_resitel_reseni'),
] ]

80
odevzdavatko/views.py

@ -60,7 +60,7 @@ class TabulkaOdevzdanychReseniView(ListView):
self.aktualni_rocnik = m.Nastaveni.get_solo().aktualni_rocnik # .get_solo() vrátí tu jedinou instanci self.aktualni_rocnik = m.Nastaveni.get_solo().aktualni_rocnik # .get_solo() vrátí tu jedinou instanci
if 'rocnik' in self.kwargs: if 'rocnik' in self.kwargs:
self.aktualni_rocnik = m.Rocnik.objects.get(rocnik=self.kwargs['rocnik']) self.aktualni_rocnik = get_object_or_404(m.Rocnik, rocnik=self.kwargs['rocnik'])
form = FiltrForm(self.request.GET, rocnik=self.aktualni_rocnik) form = FiltrForm(self.request.GET, rocnik=self.aktualni_rocnik)
if form.is_valid(): if form.is_valid():
@ -102,16 +102,21 @@ class TabulkaOdevzdanychReseniView(ListView):
) )
#self.problemy = list(filter(lambda problem: problem.rocnik() == self.aktualni_rocnik, self.problemy)) # DB HOG? # FIXME: některé problémy nemají ročník.... #self.problemy = list(filter(lambda problem: problem.rocnik() == self.aktualni_rocnik, self.problemy)) # DB HOG? # FIXME: některé problémy nemají ročník....
# NOTE: Protože řešení odkazuje přímo na Problém a QuerySet na Hodnocení je nepolymorfní, musíme porovnávat taky s nepolymorfními Problémy. # NOTE: Protože řešení odkazuje přímo na Problém a QuerySet na Hodnocení je nepolymorfní, musíme porovnávat taky s nepolymorfními Problémy.
self.problemy = self.problemy.non_polymorphic() self.problemy = self.problemy.non_polymorphic().distinct()
self.reseni = self.reseni.filter(cas_doruceni__date__gt=reseni_od, cas_doruceni__date__lte=reseni_do) self.reseni = self.reseni.filter(cas_doruceni__date__gt=reseni_od, cas_doruceni__date__lte=reseni_do)
if jen_neobodovane: if jen_neobodovane:
self.reseni = self.reseni.filter(hodnoceni__body__isnull=True) self.reseni = self.reseni.filter(hodnoceni__body__isnull=True)
self.jen_neobodovane = jen_neobodovane
def get_queryset(self): def get_queryset(self):
self.inicializuj_osy_tabulky() self.inicializuj_osy_tabulky()
qs = super().get_queryset() qs = super().get_queryset()
qs = qs.filter(problem__in=self.problemy, reseni__in=self.reseni, reseni__resitele__in=self.resitele).select_related('reseni', 'problem').prefetch_related('reseni__resitele__osoba') if self.jen_neobodovane:
qs = qs.filter(body__isnull=True)
qs = qs.filter(problem__in=self.problemy, reseni__in=self.reseni, reseni__resitele__in=self.resitele).select_related('reseni', 'problem').prefetch_related('reseni__resitele__osoba').distinct()
# FIXME tohle je ošklivé, na špatném místě a pomalé. Ale moc mě štvalo, že musím hledat správná místa v tabulce.
self.problemy = self.problemy.filter(id__in=qs.values("problem__id"))
return qs return qs
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
@ -211,6 +216,7 @@ class ReseniProblemuView(MultipleObjectTemplateResponseMixin, MultipleObjectMixi
## XXX: https://docs.djangoproject.com/en/3.1/topics/class-based-views/mixins/#avoid-anything-more-complex ## XXX: https://docs.djangoproject.com/en/3.1/topics/class-based-views/mixins/#avoid-anything-more-complex
class DetailReseniView(DetailView): class DetailReseniView(DetailView):
""" Náhled na řešení. Editace je v :py:class:`EditReseniView`. """
model = m.Reseni model = m.Reseni
template_name = 'odevzdavatko/detail.html' template_name = 'odevzdavatko/detail.html'
@ -227,18 +233,48 @@ class DetailReseniView(DetailView):
return result return result
def get_context_data(self, **kw): def get_context_data(self, **kw):
self.check_access()
ctx = super().get_context_data(**kw) ctx = super().get_context_data(**kw)
ctx['form'] = f.OhodnoceniReseniFormSet( detaily_hodnoceni = self.aktualni_hodnoceni()
initial = self.aktualni_hodnoceni() ctx["hodnoceni"] = detaily_hodnoceni
)
# Subject případného mailu (template neumí použitelně spojovat řetězce: https://stackoverflow.com/q/4386168)
ctx["predmetmailu"] = "Oprava řešení M&M "+self.reseni.problem.first().hlavni_problem.nazev
ctx["maily_vsech_resitelu"] = [y for x in self.reseni.resitele.all().values_list('osoba__email') for y in x]
return ctx
def get(self, request, *args, **kwargs):
"""
Oproti :py:class:`django.views.generic.detail.BaseDetailView`
kontroluje přístup pomocí :py:meth:`check_access`
"""
response = super().get(self, request, *args, **kwargs)
self.check_access()
return response
def check_access(self):
""" Řešitel musí být součástí řešení, jinak se na něj nemá co dívat. Případně to může být org."""
if not self.object.resitele.filter(osoba__user=self.request.user).exists() and not self.request.user.je_org:
raise PermissionDenied()
class EditReseniView(DetailReseniView):
""" Editace (hlavně hodnocení) řešení. """
def get_context_data(self, **kw):
ctx = super().get_context_data(**kw)
ctx['form'] = f.OhodnoceniReseniFormSet(initial=ctx["hodnoceni"])
ctx['poznamka_form'] = f.PoznamkaReseniForm(instance=self.reseni) ctx['poznamka_form'] = f.PoznamkaReseniForm(instance=self.reseni)
ctx['edit'] = True
return ctx return ctx
def check_access(self):
# Na orga máme nároky už v urls.py ale better safe then sorry
if not self.request.user.je_org:
raise PermissionDenied()
def hodnoceniReseniView(request, pk, *args, **kwargs): def hodnoceniReseniView(request, pk, *args, **kwargs):
reseni = get_object_or_404(m.Reseni, pk=pk) reseni = get_object_or_404(m.Reseni, pk=pk)
template_name = 'odevzdavatko/detail.html'
form_class = f.OhodnoceniReseniFormSet
success_url = reverse('odevzdavatko_detail_reseni', kwargs={'pk': pk}) success_url = reverse('odevzdavatko_detail_reseni', kwargs={'pk': pk})
# FIXME: Použit initial i tady a nebastlit hodnocení tak nízkoúrovňově # FIXME: Použit initial i tady a nebastlit hodnocení tak nízkoúrovňově
@ -270,33 +306,6 @@ def hodnoceniReseniView(request, pk, *args, **kwargs):
return redirect(success_url) return redirect(success_url)
class ResitelReseniView(DetailView):
model = m.Reseni
template_name = 'odevzdavatko/detail_resitele.html'
def aktualni_hodnoceni(self):
self.reseni = get_object_or_404(m.Reseni, id=self.kwargs['pk'])
result = []
for hodn in m.Hodnoceni.objects.filter(reseni=self.reseni):
result.append(
{
"problem": hodn.problem,
"body": hodn.body,
"feedback": hodn.feedback,
# "deadline_body": hodn.deadline_body,
}
)
return result
def get_context_data(self, **kw):
ctx = super().get_context_data(**kw)
hodnoceni = self.aktualni_hodnoceni()
if not self.reseni.resitele.filter(osoba__user=self.request.user).exists():
raise PermissionDenied()
# ctx['poznamka'] = f.PoznamkaReseniForm(instance=self.reseni)
ctx["hodnoceni"] = hodnoceni
return ctx
class PrehledOdevzdanychReseni(ListView): class PrehledOdevzdanychReseni(ListView):
@ -408,6 +417,7 @@ class NahrajReseniView(LoginRequiredMixin, CreateView):
with transaction.atomic(): with transaction.atomic():
self.object = form.save() self.object = form.save()
self.object.resitele.add(m.Resitel.objects.get(osoba__user = self.request.user)) self.object.resitele.add(m.Resitel.objects.get(osoba__user = self.request.user))
self.object.resitele.add(*form.cleaned_data["resitele"])
self.object.cas_doruceni = timezone.now() self.object.cas_doruceni = timezone.now()
self.object.forma = m.Reseni.FORMA_UPLOAD self.object.forma = m.Reseni.FORMA_UPLOAD
self.object.save() self.object.save()

2
personalni/admin.py

@ -43,7 +43,7 @@ class OrganizatorAdmin(ReverseModelAdmin):
@admin.register(m.Resitel) @admin.register(m.Resitel)
class ResitelAdmin(ReverseModelAdmin): class ResitelAdmin(ReverseModelAdmin):
search_fields = ['osoba__jmeno', 'osoba__prijmeni', 'osoba__prezdivka'] search_fields = ['osoba__jmeno', 'osoba__prijmeni', 'osoba__prezdivka']
ordering = ('osoba__jmeno','osoba__prijmeni') ordering = ('osoba__prijmeni', 'osoba__jmeno')
inline_type = 'stacked' inline_type = 'stacked'
inline_reverse = ['osoba'] inline_reverse = ['osoba']

19
personalni/forms.py

@ -32,6 +32,7 @@ class PrihlaskaForm(PasswordResetForm):
help_text='Tímto jménem se následně budeš přihlašovat pro odevzdání řešení a další činnosti v semináři') help_text='Tímto jménem se následně budeš přihlašovat pro odevzdání řešení a další činnosti v semináři')
jmeno = forms.CharField(label='Jméno', max_length=256, required=True) jmeno = forms.CharField(label='Jméno', max_length=256, required=True)
prezdivka_resitele = forms.CharField(label='Přezdívka (veřejná)', max_length=256, required=False)
prijmeni = forms.CharField(label='Příjmení', max_length=256, required=True) prijmeni = forms.CharField(label='Příjmení', max_length=256, required=True)
pohlavi_muz = forms.ChoiceField(label='Pohlaví', pohlavi_muz = forms.ChoiceField(label='Pohlaví',
choices = ((True,'muž'),(False,'žena')), required=True) choices = ((True,'muž'),(False,'žena')), required=True)
@ -105,6 +106,14 @@ class PrihlaskaForm(PasswordResetForm):
pass pass
return email return email
def clean_prezdivka_resitele(self):
prezdivka_resitele = self.cleaned_data.get('prezdivka_resitele')
if prezdivka_resitele == '':
return prezdivka_resitele
if Resitel.objects.filter(prezdivka_resitele=prezdivka_resitele).count() > 0:
raise forms.ValidationError('Přezdívka je již použita')
return prezdivka_resitele
def clean_zasilat(self): def clean_zasilat(self):
zasilat = self.cleaned_data.get('zasilat') zasilat = self.cleaned_data.get('zasilat')
ulice = self.cleaned_data.get('ulice') ulice = self.cleaned_data.get('ulice')
@ -138,6 +147,7 @@ class ProfileEditForm(forms.Form):
disabled=True) disabled=True)
jmeno = forms.CharField(label='Jméno', max_length=256, required=True) jmeno = forms.CharField(label='Jméno', max_length=256, required=True)
prezdivka_resitele = forms.CharField(label='Přezdívka (veřejná)', max_length=256, required=False)
prijmeni = forms.CharField(label='Příjmení', max_length=256, required=True) prijmeni = forms.CharField(label='Příjmení', max_length=256, required=True)
pohlavi_muz = forms.ChoiceField(label='Pohlaví', pohlavi_muz = forms.ChoiceField(label='Pohlaví',
choices = ((True,'muž'),(False,'žena')), required=True) choices = ((True,'muž'),(False,'žena')), required=True)
@ -190,6 +200,15 @@ class ProfileEditForm(forms.Form):
# pass # pass
# return username # return username
# #
def clean_prezdivka_resitele(self):
prezdivka_resitele = self.cleaned_data.get('prezdivka_resitele')
if prezdivka_resitele == '':
return prezdivka_resitele
if Resitel.objects.filter(prezdivka_resitele=prezdivka_resitele).exclude(osoba__user__username=self.username).count() > 0:
raise forms.ValidationError('Přezdívka je již použita')
return prezdivka_resitele
def clean_email(self): def clean_email(self):
err_logger = logging.getLogger('seminar.prihlaska.problem') err_logger = logging.getLogger('seminar.prihlaska.problem')
email = self.cleaned_data.get('email') email = self.cleaned_data.get('email')

1
personalni/templates/personalni/udaje/edit.html

@ -44,6 +44,7 @@
</h4> </h4>
<table class="form"> <table class="form">
{% include "personalni/udaje/prihlaska_field.html" with field=form.jmeno %} {% include "personalni/udaje/prihlaska_field.html" with field=form.jmeno %}
{% include "personalni/udaje/prihlaska_field.html" with field=form.prezdivka_resitele %}
{% include "personalni/udaje/prihlaska_field.html" with field=form.prijmeni %} {% include "personalni/udaje/prihlaska_field.html" with field=form.prijmeni %}
{% include "personalni/udaje/prihlaska_field.html" with field=form.pohlavi_muz%} {% include "personalni/udaje/prihlaska_field.html" with field=form.pohlavi_muz%}
{% include "personalni/udaje/prihlaska_field.html" with field=form.email %} {% include "personalni/udaje/prihlaska_field.html" with field=form.email %}

2
personalni/templates/personalni/udaje/gdpr.html

@ -11,7 +11,7 @@ Získáváme od Tebe údaje vyplněné v přihlášce do semináře (jméno, př
Slibujeme Ti, že Tvá osobní data nezneužijeme k ničemu, co by nesouviselo s M&amp;M nebo s dalšími aktivitami Matfyzu, a nikdy je nepředáme nikomu cizímu. Údaje využíváme k zajištění chodu semináře a také je sdílíme s ostatními propagačními akcemi Matfyzu, abychom mohli vyhodnocovat úspěšnost akcí. Pokud budeš mít zájem, budeme Ti také posílat zajímavé zprávy a novinky týkajíci se Matfyzu. Slibujeme Ti, že Tvá osobní data nezneužijeme k ničemu, co by nesouviselo s M&amp;M nebo s dalšími aktivitami Matfyzu, a nikdy je nepředáme nikomu cizímu. Údaje využíváme k zajištění chodu semináře a také je sdílíme s ostatními propagačními akcemi Matfyzu, abychom mohli vyhodnocovat úspěšnost akcí. Pokud budeš mít zájem, budeme Ti také posílat zajímavé zprávy a novinky týkajíci se Matfyzu.
</p> </p>
<p class="gdpr"> <p class="gdpr">
Veřejně vystavujeme pouze výsledkové listiny, které také uchováváme pro archivní účely. Pokud ale z nějakého důvodu nebudeš chtít mít své jméno či školu uvedené ve výsledkové listině, není problém to zařídit, napiš nám. Z tištěných materiálů samozřejmě údaje už odstranit nemůžeme. Veřejně vystavujeme pouze seznam přezdívek (pro výběr spoluřešitelů k řešení) a výsledkové listiny, které také uchováváme pro archivní účely. Pokud ale z nějakého důvodu nebudeš chtít mít své jméno či školu uvedené ve výsledkové listině, není problém to zařídit, napiš nám. Z tištěných materiálů samozřejmě údaje už odstranit nemůžeme.
</p> </p>
<p class="gdpr"> <p class="gdpr">
Na soustředěních a dalších akcích semináře navíc pořizujeme fotografie a videozáznamy a používáme je ke zpravodajským a propagačním účelům. Pro propagační účely si od Tebe vyžádáme samostatný souhlas na začátku akce. Na soustředěních a dalších akcích semináře navíc pořizujeme fotografie a videozáznamy a používáme je ke zpravodajským a propagačním účelům. Pro propagační účely si od Tebe vyžádáme samostatný souhlas na začátku akce.

1
personalni/templates/personalni/udaje/prihlaska.html

@ -46,6 +46,7 @@
</h4> </h4>
<table class="form"> <table class="form">
{% include "personalni/udaje/prihlaska_field.html" with field=form.jmeno %} {% include "personalni/udaje/prihlaska_field.html" with field=form.jmeno %}
{% include "personalni/udaje/prihlaska_field.html" with field=form.prezdivka_resitele %}
{% include "personalni/udaje/prihlaska_field.html" with field=form.prijmeni %} {% include "personalni/udaje/prihlaska_field.html" with field=form.prijmeni %}
{% include "personalni/udaje/prihlaska_field.html" with field=form.pohlavi_muz%} {% include "personalni/udaje/prihlaska_field.html" with field=form.pohlavi_muz%}
{% include "personalni/udaje/prihlaska_field.html" with field=form.email %} {% include "personalni/udaje/prihlaska_field.html" with field=form.email %}

2
personalni/views.py

@ -160,6 +160,7 @@ def resitelEditView(request):
if resitel_edit: if resitel_edit:
## Změny v řešiteli ## Změny v řešiteli
resitel_edit.prezdivka_resitele = fcd['prezdivka_resitele'] if fcd['prezdivka_resitele'] != '' else None
resitel_edit.skola = fcd['skola'] resitel_edit.skola = fcd['skola']
resitel_edit.rok_maturity = fcd['rok_maturity'] resitel_edit.rok_maturity = fcd['rok_maturity']
resitel_edit.zasilat = fcd['zasilat'] resitel_edit.zasilat = fcd['zasilat']
@ -263,6 +264,7 @@ def prihlaskaView(request):
err_logger.warning(f'Zaregistrovala se osoba s kolizním jménem. ID osob: {[o.id for o in kolize]}') err_logger.warning(f'Zaregistrovala se osoba s kolizním jménem. ID osob: {[o.id for o in kolize]}')
r = s.Resitel( r = s.Resitel(
prezdivka_resitele=fcd['prezdivka_resitele'],
rok_maturity = fcd['rok_maturity'], rok_maturity = fcd['rok_maturity'],
zasilat = fcd['zasilat'], zasilat = fcd['zasilat'],
zasilat_cislo_emailem = fcd['zasilat_cislo_emailem'] zasilat_cislo_emailem = fcd['zasilat_cislo_emailem']

18
seminar/migrations/0110_resitel_prezdivka.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2022-11-21 22:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('seminar', '0109_hodnoceni_feedback'),
]
operations = [
migrations.AddField(
model_name='resitel',
name='prezdivka_resitele',
field=models.CharField(blank=True, max_length=256, null=True, unique=True, verbose_name='přezdívka řešitele'),
),
]

18
seminar/migrations/0111_nikam2nezasilat_papirove.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2023-01-30 19:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('seminar', '0110_resitel_prezdivka'),
]
operations = [
migrations.AlterField(
model_name='resitel',
name='zasilat',
field=models.CharField(choices=[('domu', 'Domů'), ('do_skoly', 'Do školy'), ('nikam', 'Nezasílat papírově')], default='domu', max_length=32, verbose_name='kam zasílat'),
),
]

18
seminar/models/base.py

@ -4,18 +4,18 @@ from django.db import models
class SeminarModelBase(models.Model): class SeminarModelBase(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def verejne(self): def verejne(self):
return False return False
# def get_absolute_url(self): # def get_absolute_url(self):
# return "https://" + str(get_current_site(None)) + self.verejne_url() # return "https://" + str(get_current_site(None)) + self.verejne_url()
def admin_url(self): def admin_url(self):
model_name = self.__class__.__name__.lower() model_name = self.__class__.__name__.lower()
return reverse('admin:seminar_{}_change'.format(model_name), args=(self.id, )) return reverse('admin:seminar_{}_change'.format(model_name), args=(self.id, ))
# def verejne_url(self): # def verejne_url(self):
# return None # return None

42
seminar/models/novinky.py

@ -9,30 +9,30 @@ from . import personalni as pm
@reversion.register(ignore_duplicates=True) @reversion.register(ignore_duplicates=True)
class Novinky(models.Model): class Novinky(models.Model):
class Meta: class Meta:
verbose_name = 'Novinka' verbose_name = 'Novinka'
verbose_name_plural = 'Novinky' verbose_name_plural = 'Novinky'
ordering = ['-datum'] ordering = ['-datum']
datum = models.DateField(auto_now_add=True) datum = models.DateField(auto_now_add=True)
text = models.TextField('Text novinky', blank=True, null=True) text = models.TextField('Text novinky', blank=True, null=True)
obrazek = models.ImageField('Obrázek', upload_to='image_novinky/%Y/%m/%d/', obrazek = models.ImageField('Obrázek', upload_to='image_novinky/%Y/%m/%d/',
null=True, blank=True) null=True, blank=True)
obrazek_maly = ImageSpecField(source='obrazek', obrazek_maly = ImageSpecField(source='obrazek',
processors=[ processors=[
ResizeToFit(350, 200, upscale=False) ResizeToFit(350, 200, upscale=False)
], ],
options={'quality': 95}) options={'quality': 95})
autor = models.ForeignKey(pm.Organizator, verbose_name='Autor novinky', null=True, autor = models.ForeignKey(pm.Organizator, verbose_name='Autor novinky', null=True,
on_delete=models.SET_NULL) on_delete=models.SET_NULL)
zverejneno = models.BooleanField('Zveřejněno', default=False) zverejneno = models.BooleanField('Zveřejněno', default=False)
def __str__(self): def __str__(self):
if self.text: if self.text:
return '[' + str(self.datum) + '] ' + self.text[0:50] return '[' + str(self.datum) + '] ' + self.text[0:50]
else: else:
return '[' + str(self.datum) + '] ' return '[' + str(self.datum) + '] '

234
seminar/models/odevzdavatko.py

@ -18,68 +18,68 @@ from seminar.models import base as bm
@reversion.register(ignore_duplicates=True) @reversion.register(ignore_duplicates=True)
class Reseni(bm.SeminarModelBase): class Reseni(bm.SeminarModelBase):
class Meta: class Meta:
db_table = 'seminar_reseni' db_table = 'seminar_reseni'
verbose_name = 'Řešení' verbose_name = 'Řešení'
verbose_name_plural = 'Řešení' verbose_name_plural = 'Řešení'
#ordering = ['-problem', 'resitele'] # FIXME: Takhle to chceme, ale nefunguje to. #ordering = ['-problem', 'resitele'] # FIXME: Takhle to chceme, ale nefunguje to.
ordering = ['-cas_doruceni'] ordering = ['-cas_doruceni']
# Interní ID # Interní ID
id = models.AutoField(primary_key = True) id = models.AutoField(primary_key = True)
# Ke každé dvojici řešní a problém existuje nanejvýš jedno hodnocení, doplnění vazby. # Ke každé dvojici řešní a problém existuje nanejvýš jedno hodnocení, doplnění vazby.
problem = models.ManyToManyField(am.Problem, verbose_name='problém', help_text='Problém', problem = models.ManyToManyField(am.Problem, verbose_name='problém', help_text='Problém',
through='Hodnoceni') through='Hodnoceni')
resitele = models.ManyToManyField(pm.Resitel, verbose_name='autoři řešení', resitele = models.ManyToManyField(pm.Resitel, verbose_name='autoři řešení',
help_text='Seznam autorů řešení', through='Reseni_Resitele') help_text='Seznam autorů řešení', through='Reseni_Resitele')
cas_doruceni = models.DateTimeField('čas_doručení', default=timezone.now, blank=True) cas_doruceni = models.DateTimeField('čas_doručení', default=timezone.now, blank=True)
FORMA_PAPIR = 'papir' FORMA_PAPIR = 'papir'
FORMA_EMAIL = 'email' FORMA_EMAIL = 'email'
FORMA_UPLOAD = 'upload' FORMA_UPLOAD = 'upload'
FORMA_CHOICES = [ FORMA_CHOICES = [
(FORMA_PAPIR, 'Papírové řešení'), (FORMA_PAPIR, 'Papírové řešení'),
(FORMA_EMAIL, 'Emailem'), (FORMA_EMAIL, 'Emailem'),
(FORMA_UPLOAD, 'Upload přes web'), (FORMA_UPLOAD, 'Upload přes web'),
] ]
forma = models.CharField('forma řešení', max_length=16, choices=FORMA_CHOICES, blank=False, forma = models.CharField('forma řešení', max_length=16, choices=FORMA_CHOICES, blank=False,
default=FORMA_EMAIL) default=FORMA_EMAIL)
text_cely = models.OneToOneField('ReseniNode', verbose_name='Plná verze textu řešení', text_cely = models.OneToOneField('ReseniNode', verbose_name='Plná verze textu řešení',
blank=True, null=True, related_name="reseni_cely_set", blank=True, null=True, related_name="reseni_cely_set",
on_delete=models.PROTECT) on_delete=models.PROTECT)
poznamka = models.TextField('neveřejná poznámka', blank=True, poznamka = models.TextField('neveřejná poznámka', blank=True,
help_text='Neveřejná poznámka k řešení (plain text)') help_text='Neveřejná poznámka k řešení (plain text)')
zverejneno = models.BooleanField('řešení zveřejněno', default=False, zverejneno = models.BooleanField('řešení zveřejněno', default=False,
help_text='Udává, zda je řešení zveřejněno') help_text='Udává, zda je řešení zveřejněno')
def verejne_url(self): def verejne_url(self):
return str(reverse_lazy('odevzdavatko_detail_reseni', args=[self.id])) return str(reverse_lazy('odevzdavatko_detail_reseni', args=[self.id]))
def absolute_url(self): def absolute_url(self):
return "https://" + str(get_current_site(None)) + self.verejne_url() return "https://" + str(get_current_site(None)) + self.verejne_url()
# má OneToOneField s: # má OneToOneField s:
# Konfera # Konfera
# má ForeignKey s: # má ForeignKey s:
# Hodnoceni # Hodnoceni
def sum_body(self): def sum_body(self):
return self.hodnoceni_set.all().aggregate(Sum('body'))["body__sum"] return self.hodnoceni_set.all().aggregate(Sum('body'))["body__sum"]
def __str__(self): def __str__(self):
return "{}({}): {}({})".format(self.resitele.first(),len(self.resitele.all()), self.problem.first() ,len(self.problem.all())) return "{}({}): {}({})".format(self.resitele.first(),len(self.resitele.all()), self.problem.first() ,len(self.problem.all()))
# NOTE: Potenciální DB HOG (bez select_related) # NOTE: Potenciální DB HOG (bez select_related)
def deadline_reseni(self): def deadline_reseni(self):
return am.Deadline.objects.filter(deadline__gte=self.cas_doruceni).order_by("deadline").first() return am.Deadline.objects.filter(deadline__gte=self.cas_doruceni).order_by("deadline").first()
## Pravdepodobne uz nebude potreba: ## Pravdepodobne uz nebude potreba:
# def save(self, *args, **kwargs): # def save(self, *args, **kwargs):
@ -89,112 +89,112 @@ class Reseni(bm.SeminarModelBase):
# super(Reseni, self).save(*args, **kwargs) # super(Reseni, self).save(*args, **kwargs)
class Hodnoceni(bm.SeminarModelBase): class Hodnoceni(bm.SeminarModelBase):
class Meta: class Meta:
db_table = 'seminar_hodnoceni' db_table = 'seminar_hodnoceni'
verbose_name = 'Hodnocení' verbose_name = 'Hodnocení'
verbose_name_plural = 'Hodnocení' verbose_name_plural = 'Hodnocení'
# Interní ID # Interní ID
id = models.AutoField(primary_key = True) id = models.AutoField(primary_key = True)
body = models.DecimalField(max_digits=8, decimal_places=1, verbose_name='body', body = models.DecimalField(max_digits=8, decimal_places=1, verbose_name='body',
blank=True, null=True) blank=True, null=True)
cislo_body = models.ForeignKey(am.Cislo, verbose_name='číslo pro body', cislo_body = models.ForeignKey(am.Cislo, verbose_name='číslo pro body',
related_name='hodnoceni', blank=True, null=True, on_delete=models.PROTECT) related_name='hodnoceni', blank=True, null=True, on_delete=models.PROTECT)
# V ročníku < 26 nastaveno na deadline vygenerovaný pro původní cislo_body # V ročníku < 26 nastaveno na deadline vygenerovaný pro původní cislo_body
deadline_body = models.ForeignKey(am.Deadline, verbose_name='deadline pro body', deadline_body = models.ForeignKey(am.Deadline, verbose_name='deadline pro body',
related_name='hodnoceni', blank=True, null=True, on_delete=models.PROTECT) related_name='hodnoceni', blank=True, null=True, on_delete=models.PROTECT)
reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE) reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE)
problem = models.ForeignKey(am.Problem, verbose_name='problém', problem = models.ForeignKey(am.Problem, verbose_name='problém',
related_name='hodnoceni', on_delete=models.PROTECT) related_name='hodnoceni', on_delete=models.PROTECT)
feedback = models.TextField('zpětná vazba', blank=True, default='', help_text='Zpětná vazba řešiteli (plain text)') feedback = models.TextField('zpětná vazba', blank=True, default='', help_text='Zpětná vazba řešiteli (plain text)')
def __str__(self): def __str__(self):
return "{}, {}, {}".format(self.problem, self.reseni, self.body) return "{}, {}, {}".format(self.problem, self.reseni, self.body)
def generate_filename(self, filename): def generate_filename(self, filename):
return os.path.join( return os.path.join(
settings.SEMINAR_RESENI_DIR, settings.SEMINAR_RESENI_DIR,
am.aux_generate_filename(self, filename) am.aux_generate_filename(self, filename)
) )
@reversion.register(ignore_duplicates=True) @reversion.register(ignore_duplicates=True)
class PrilohaReseni(bm.SeminarModelBase): class PrilohaReseni(bm.SeminarModelBase):
class Meta: class Meta:
db_table = 'seminar_priloha_reseni' db_table = 'seminar_priloha_reseni'
verbose_name = 'Příloha řešení' verbose_name = 'Příloha řešení'
verbose_name_plural = 'Přílohy řešení' verbose_name_plural = 'Přílohy řešení'
ordering = ['reseni', 'vytvoreno'] ordering = ['reseni', 'vytvoreno']
# Interní ID # Interní ID
id = models.AutoField(primary_key = True) id = models.AutoField(primary_key = True)
reseni = models.ForeignKey(Reseni, verbose_name='řešení', related_name='prilohy', reseni = models.ForeignKey(Reseni, verbose_name='řešení', related_name='prilohy',
on_delete=models.CASCADE) on_delete=models.CASCADE)
vytvoreno = models.DateTimeField('vytvořeno', default=timezone.now, blank=True, editable=False) vytvoreno = models.DateTimeField('vytvořeno', default=timezone.now, blank=True, editable=False)
soubor = models.FileField('soubor', upload_to = generate_filename) soubor = models.FileField('soubor', upload_to = generate_filename)
poznamka = models.TextField('neveřejná poznámka', blank=True, poznamka = models.TextField('neveřejná poznámka', blank=True,
help_text='Neveřejná poznámka k příloze řešení (plain text), např. o původu') help_text='Neveřejná poznámka k příloze řešení (plain text), např. o původu')
res_poznamka = models.TextField('poznámka řešitele', blank=True, res_poznamka = models.TextField('poznámka řešitele', blank=True,
help_text='Poznámka k příloze řešení, např. co daný soubor obsahuje') help_text='Poznámka k příloze řešení, např. co daný soubor obsahuje')
def __str__(self): def __str__(self):
return str(self.soubor) return str(self.soubor)
def split(self): def split(self):
"Vrátí cestu rozsekanou po složkách. To se hodí v templatech" "Vrátí cestu rozsekanou po složkách. To se hodí v templatech"
# Věřím, že tohle funguje, případně použít os.path nebo pathlib. # Věřím, že tohle funguje, případně použít os.path nebo pathlib.
return self.soubor.url.split('/') return self.soubor.url.split('/')
# Vazebna tabulka. Mozna se generuje automaticky. # Vazebna tabulka. Mozna se generuje automaticky.
@reversion.register(ignore_duplicates=True) @reversion.register(ignore_duplicates=True)
class Reseni_Resitele(models.Model): class Reseni_Resitele(models.Model):
class Meta: class Meta:
db_table = 'seminar_reseni_resitele' db_table = 'seminar_reseni_resitele'
verbose_name = 'Řešení řešitelů' verbose_name = 'Řešení řešitelů'
verbose_name_plural = 'Řešení řešitelů' verbose_name_plural = 'Řešení řešitelů'
ordering = ['reseni', 'resitele'] ordering = ['reseni', 'resitele']
# Interní ID # Interní ID
id = models.AutoField(primary_key = True) id = models.AutoField(primary_key = True)
resitele = models.ForeignKey(pm.Resitel, verbose_name='řešitel', on_delete=models.PROTECT) resitele = models.ForeignKey(pm.Resitel, verbose_name='řešitel', on_delete=models.PROTECT)
reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE) reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE)
# podil - jakou merou se ktery resitel podilel na danem reseni # podil - jakou merou se ktery resitel podilel na danem reseni
# - pouziti v budoucnu, pokud by resitele nemeli dostat vsichni stejne bodu za spolecne reseni # - pouziti v budoucnu, pokud by resitele nemeli dostat vsichni stejne bodu za spolecne reseni
def __str__(self): def __str__(self):
return '{} od {}'.format(self.reseni, self.resitel) return '{} od {}'.format(self.reseni, self.resitel)
# NOTE: Poteciální DB HOG bez select_related # NOTE: Poteciální DB HOG bez select_related
class ReseniNode(tm.TreeNode): class ReseniNode(tm.TreeNode):
class Meta: class Meta:
db_table = 'seminar_nodes_otistene_reseni' db_table = 'seminar_nodes_otistene_reseni'
verbose_name = 'Otištěné řešení (Node)' verbose_name = 'Otištěné řešení (Node)'
verbose_name_plural = 'Otištěná řešení (Node)' verbose_name_plural = 'Otištěná řešení (Node)'
reseni = models.ForeignKey(Reseni, reseni = models.ForeignKey(Reseni,
on_delete=models.PROTECT, on_delete=models.PROTECT,
verbose_name = 'reseni') verbose_name = 'reseni')
def aktualizuj_nazev(self): def aktualizuj_nazev(self):
self.nazev = "ReseniNode: "+str(self.reseni) self.nazev = "ReseniNode: "+str(self.reseni)
def getOdkazStr(self): def getOdkazStr(self):
return str(self.reseni) return str(self.reseni)

4
seminar/models/personalni.py

@ -211,6 +211,8 @@ class Resitel(SeminarModelBase):
# Interní ID # Interní ID
id = models.AutoField(primary_key = True) id = models.AutoField(primary_key = True)
prezdivka_resitele = models.CharField('přezdívka řešitele', blank=True, null=True, max_length=256, unique=True)
osoba = models.OneToOneField(Osoba, blank=False, null=False, verbose_name='osoba', osoba = models.OneToOneField(Osoba, blank=False, null=False, verbose_name='osoba',
on_delete=models.PROTECT) on_delete=models.PROTECT)
@ -227,7 +229,7 @@ class Resitel(SeminarModelBase):
ZASILAT_CHOICES = [ ZASILAT_CHOICES = [
(ZASILAT_DOMU, 'Domů'), (ZASILAT_DOMU, 'Domů'),
(ZASILAT_DO_SKOLY, 'Do školy'), (ZASILAT_DO_SKOLY, 'Do školy'),
(ZASILAT_NIKAM, 'Nikam'), (ZASILAT_NIKAM, 'Nezasílat papírově'),
] ]
zasilat = models.CharField('kam zasílat', max_length=32, choices=ZASILAT_CHOICES, blank=False, default=ZASILAT_DOMU) zasilat = models.CharField('kam zasílat', max_length=32, choices=ZASILAT_CHOICES, blank=False, default=ZASILAT_DOMU)

32
seminar/models/tvorba.py

@ -270,21 +270,27 @@ class Cislo(SeminarModelBase):
'Vaše M&M\n'.format(odkaz) 'Vaše M&M\n'.format(odkaz)
# Prijemci e-mailu # Prijemci e-mailu
emaily = map(lambda r: r.osoba.email, filter(lambda r: r.zasilat_cislo_emailem, aktivniResitele(self))) resitele_vsichni = aktivniResitele(self).filter(zasilat_cislo_emailem=True)
if not settings.POSLI_MAILOVOU_NOTIFIKACI: def posli(text, resitele):
print("Poslal bych upozornění na tyto adresy: ", " ".join(emaily)) emaily = map(lambda resitel: resitel.osoba.email, resitele)
return if not settings.POSLI_MAILOVOU_NOTIFIKACI:
print("Poslal bych upozornění na tyto adresy: ", " ".join(emaily))
return
email = EmailMessage(
subject=predmet,
body=text,
from_email=poslat_z_mailu,
bcc=list(emaily)
#bcc = příjemci skryté kopie
)
email = EmailMessage( email.send()
subject=predmet,
body=text_mailu,
from_email=poslat_z_mailu,
bcc=list(emaily)
#bcc = příjemci skryté kopie
)
email.send() posli(text_mailu, resitele_vsichni.filter(zasilat=pm.Resitel.ZASILAT_NIKAM))
posli(text_mailu + 'P. S. Také by vám brzy měla přijít papírová verze. Připomínáme, že pokud papírovou verzi čísla nevyužijete, můžete v https://mam.mff.cuni.cz/resitel/osobni-udaje/ zaškrtnout, abychom vám ji neposílali. Děkujeme. (Čísla vždy můžete nalézt v našem archivu a dál vám budou chodit e-mailem.)\n',
resitele_vsichni.exclude(zasilat=pm.Resitel.ZASILAT_NIKAM))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)

2
seminar/templatetags/deadliny.py

@ -26,7 +26,7 @@ def deadline_html(deadline: m.Deadline):
m.Deadline.TYP_PRVNI_A_SOUS: 'sous_deadline', m.Deadline.TYP_PRVNI_A_SOUS: 'sous_deadline',
m.Deadline.TYP_CISLA: 'final_deadline', m.Deadline.TYP_CISLA: 'final_deadline',
} }
return mark_safe(f'<span class="{classes[deadline.typ]}">{text}</span>') return mark_safe(f'<span class="{classes[deadline.typ]}" title="{deadline}">{text}</span>')
@register.filter(name='zkrat_nazev_problemu') @register.filter(name='zkrat_nazev_problemu')
def zkrat_nazev_problemu(nazev,width): def zkrat_nazev_problemu(nazev,width):

18
seminar/testutils.py

@ -474,15 +474,15 @@ def get_text():
def gen_dlouhe_tema(rnd, organizatori, rocnik, nazev, obor, kod): def gen_dlouhe_tema(rnd, organizatori, rocnik, nazev, obor, kod):
tema = Tema.objects.create( tema = Tema.objects.create(
nazev=nazev, nazev=nazev,
stav=Problem.STAV_ZADANY, stav=Problem.STAV_ZADANY,
zamereni="M", zamereni="M",
autor=rnd.choice(organizatori), autor=rnd.choice(organizatori),
garant=rnd.choice(organizatori), garant=rnd.choice(organizatori),
kod=str(kod), kod=str(kod),
tema_typ=rnd.choice(Tema.TEMA_CHOICES)[0], tema_typ=rnd.choice(Tema.TEMA_CHOICES)[0],
rocnik=rocnik, rocnik=rocnik,
abstrakt = lorem.paragraph() abstrakt = lorem.paragraph()
) )
# Generování struktury k tématu # Generování struktury k tématu

20
seminar/views/views_all.py

@ -349,7 +349,7 @@ def resiteleRocnikuCsvExportView(request, rocnik):
assert request.method in ('GET', 'HEAD') assert request.method in ('GET', 'HEAD')
return dataResiteluCsvResponse( return dataResiteluCsvResponse(
utils.resi_v_rocniku( utils.resi_v_rocniku(
m.Rocnik.objects.get(rocnik=rocnik) get_object_or_404(m.Rocnik, rocnik=rocnik)
) )
) )
@ -442,17 +442,17 @@ class OdmenyView(generic.TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
fromcislo = Cislo.objects.get(rocnik=self.kwargs.get('frocnik'), poradi=self.kwargs.get('fcislo')) fromcislo = get_object_or_404(Cislo, rocnik=self.kwargs.get('frocnik'), poradi=self.kwargs.get('fcislo'))
tocislo = Cislo.objects.get(rocnik=self.kwargs.get('trocnik'), poradi=self.kwargs.get('tcislo')) tocislo = get_object_or_404(Cislo, rocnik=self.kwargs.get('trocnik'), poradi=self.kwargs.get('tcislo'))
resitele = aktivniResitele(tocislo) resitele = aktivniResitele(tocislo)
def get_diff(from_deadline: Deadline, to_deadline: Deadline): def get_diff(from_deadline: Deadline, to_deadline: Deadline):
frombody = body_resitelu(resitele=resitele, jen_verejne=False, do=from_deadline) frombody = body_resitelu(resitele=resitele, jen_verejne=False, do=from_deadline)
tobody = body_resitelu(resitele=resitele, jen_verejne=False, do=to_deadline) tobody = body_resitelu(resitele=resitele, jen_verejne=False, do=to_deadline)
outlist = [] outlist = []
for (aid, tbody) in tobody.items(): for resitel in resitele:
fbody = frombody.get(aid,0) fbody = frombody.get(resitel.id, 0)
resitel = Resitel.objects.get(pk=aid) tbody = tobody.get(resitel.id, 0)
ftitul = resitel.get_titul(fbody) ftitul = resitel.get_titul(fbody)
ttitul = resitel.get_titul(tbody) ttitul = resitel.get_titul(tbody)
if ftitul != ttitul: if ftitul != ttitul:
@ -554,7 +554,7 @@ class RocnikVysledkovkaView(RocnikView):
#vypise na stranku textovy obsah vyTeXane vysledkovky k okopirovani #vypise na stranku textovy obsah vyTeXane vysledkovky k okopirovani
def cisloObalkyView(request, rocnik, cislo): def cisloObalkyView(request, rocnik, cislo):
realne_cislo = Cislo.objects.get(poradi=cislo, rocnik__rocnik=rocnik) realne_cislo = get_object_or_404(Cislo, poradi=cislo, rocnik__rocnik=rocnik)
return obalkyView(request, aktivniResitele(realne_cislo)) return obalkyView(request, aktivniResitele(realne_cislo))
@ -580,14 +580,14 @@ def TitulyViewRocnik(request, rocnik):
def TitulyView(request, rocnik, cislo): def TitulyView(request, rocnik, cislo):
""" View pro stažení makra titulů v TeXu.""" """ View pro stažení makra titulů v TeXu."""
rocnik_obj = Rocnik.objects.get(rocnik = rocnik) rocnik_obj = get_object_or_404(Rocnik, rocnik = rocnik)
resitele = Resitel.objects.filter(rok_maturity__gte = rocnik_obj.prvni_rok) resitele = Resitel.objects.filter(rok_maturity__gte = rocnik_obj.prvni_rok)
asciijmena = [] asciijmena = []
jmenovci = False # detekuje, zda jsou dva řešitelé jmenovci (modulo nabodeníčka), jmenovci = False # detekuje, zda jsou dva řešitelé jmenovci (modulo nabodeníčka),
# pokud ano, vrátí se jako true # pokud ano, vrátí se jako true
if cislo is not None: if cislo is not None:
cislo_obj = Cislo.objects.get(rocnik=rocnik_obj, poradi=cislo) cislo_obj = get_object_or_404(Cislo, rocnik=rocnik_obj, poradi=cislo)
slovnik_s_body = body_resitelu(do=cislo_obj.zlomovy_deadline_pro_papirove_cislo(), jen_verejne=False) slovnik_s_body = body_resitelu(do=cislo_obj.zlomovy_deadline_pro_papirove_cislo(), jen_verejne=False)
else: else:
slovnik_s_body = body_resitelu(do=Deadline.objects.filter(cislo__rocnik=rocnik_obj).last(), jen_verejne=False) slovnik_s_body = body_resitelu(do=Deadline.objects.filter(cislo__rocnik=rocnik_obj).last(), jen_verejne=False)
@ -686,7 +686,7 @@ def formularOKView(request, text=''):
] ]
context = { context = {
'odkazy': odkazy, 'odkazy': odkazy,
'text': text, 'text': text,
} }
return render(request, template_name, context) return render(request, template_name, context)

50
soustredeni/admin.py

@ -6,38 +6,38 @@ from seminar.models import soustredeni as m
class SoustredeniUcastniciInline(admin.TabularInline): class SoustredeniUcastniciInline(admin.TabularInline):
model = m.Soustredeni_Ucastnici model = m.Soustredeni_Ucastnici
extra = 1 extra = 1
fields = ['resitel','poznamka'] fields = ['resitel','poznamka']
autocomplete_fields = ['resitel'] autocomplete_fields = ['resitel']
ordering = ['resitel__osoba__jmeno', 'resitel__osoba__prijmeni'] ordering = ['resitel__osoba__jmeno', 'resitel__osoba__prijmeni']
formfield_overrides = { formfield_overrides = {
models.TextField: {'widget': widgets.TextInput} models.TextField: {'widget': widgets.TextInput}
} }
def get_queryset(self,request): def get_queryset(self,request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.select_related('resitel','soustredeni') return qs.select_related('resitel','soustredeni')
class SoustredeniOrganizatoriInline(admin.TabularInline): class SoustredeniOrganizatoriInline(admin.TabularInline):
model = m.Soustredeni.organizatori.through model = m.Soustredeni.organizatori.through
extra = 1 extra = 1
fields = ['organizator','poznamka'] fields = ['organizator','poznamka']
autocomplete_fields = ['organizator'] autocomplete_fields = ['organizator']
ordering = ['organizator__osoba__jmeno','organizator__prijmeni'] ordering = ['organizator__osoba__jmeno','organizator__prijmeni']
formfield_overrides = { formfield_overrides = {
models.TextField: {'widget': widgets.TextInput} models.TextField: {'widget': widgets.TextInput}
} }
def get_queryset(self,request): def get_queryset(self,request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.select_related('organizator', 'soustredeni') return qs.select_related('organizator', 'soustredeni')
@admin.register(m.Soustredeni) @admin.register(m.Soustredeni)
class SoustredeniAdmin(admin.ModelAdmin): class SoustredeniAdmin(admin.ModelAdmin):
model = m.Soustredeni model = m.Soustredeni
inline_type = 'tabular' inline_type = 'tabular'
inlines = [SoustredeniUcastniciInline, SoustredeniOrganizatoriInline] inlines = [SoustredeniUcastniciInline, SoustredeniOrganizatoriInline]

90
treenode/admin.py

@ -9,80 +9,80 @@ import seminar.models as m
@admin.register(m.TreeNode) @admin.register(m.TreeNode)
class TreeNodeAdmin(PolymorphicParentModelAdmin): class TreeNodeAdmin(PolymorphicParentModelAdmin):
base_model = m.TreeNode base_model = m.TreeNode
child_models = [ child_models = [
m.RocnikNode, m.RocnikNode,
m.CisloNode, m.CisloNode,
m.MezicisloNode, m.MezicisloNode,
m.TemaVCisleNode, m.TemaVCisleNode,
m.UlohaZadaniNode, m.UlohaZadaniNode,
m.PohadkaNode, m.PohadkaNode,
m.UlohaVzorakNode, m.UlohaVzorakNode,
m.TextNode, m.TextNode,
m.CastNode, m.CastNode,
m.OrgTextNode, m.OrgTextNode,
] ]
actions = ['aktualizuj_nazvy'] actions = ['aktualizuj_nazvy']
# XXX: nejspíš je to totální DB HOG, nechcete to použít moc často. # XXX: nejspíš je to totální DB HOG, nechcete to použít moc často.
def aktualizuj_nazvy(self, request, queryset): def aktualizuj_nazvy(self, request, queryset):
newqs = queryset.get_real_instances() newqs = queryset.get_real_instances()
for tn in newqs: for tn in newqs:
tn.aktualizuj_nazev() tn.aktualizuj_nazev()
tn.save() tn.save()
self.message_user(request, "Názvy aktualizovány.") self.message_user(request, "Názvy aktualizovány.")
aktualizuj_nazvy.short_description = "Aktualizuj vybraným TreeNodům názvy" aktualizuj_nazvy.short_description = "Aktualizuj vybraným TreeNodům názvy"
@admin.register(m.RocnikNode) @admin.register(m.RocnikNode)
class RocnikNodeAdmin(PolymorphicChildModelAdmin): class RocnikNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.RocnikNode base_model = m.RocnikNode
show_in_index = True show_in_index = True
@admin.register(m.CisloNode) @admin.register(m.CisloNode)
class CisloNodeAdmin(PolymorphicChildModelAdmin): class CisloNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.CisloNode base_model = m.CisloNode
show_in_index = True show_in_index = True
@admin.register(m.MezicisloNode) @admin.register(m.MezicisloNode)
class MezicisloNodeAdmin(PolymorphicChildModelAdmin): class MezicisloNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.MezicisloNode base_model = m.MezicisloNode
show_in_index = True show_in_index = True
@admin.register(m.TemaVCisleNode) @admin.register(m.TemaVCisleNode)
class TemaVCisleNodeAdmin(PolymorphicChildModelAdmin): class TemaVCisleNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.TemaVCisleNode base_model = m.TemaVCisleNode
show_in_index = True show_in_index = True
@admin.register(m.UlohaZadaniNode) @admin.register(m.UlohaZadaniNode)
class UlohaZadaniNodeAdmin(PolymorphicChildModelAdmin): class UlohaZadaniNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.UlohaZadaniNode base_model = m.UlohaZadaniNode
show_in_index = True show_in_index = True
@admin.register(m.PohadkaNode) @admin.register(m.PohadkaNode)
class PohadkaNodeAdmin(PolymorphicChildModelAdmin): class PohadkaNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.PohadkaNode base_model = m.PohadkaNode
show_in_index = True show_in_index = True
@admin.register(m.UlohaVzorakNode) @admin.register(m.UlohaVzorakNode)
class UlohaVzorakNodeAdmin(PolymorphicChildModelAdmin): class UlohaVzorakNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.UlohaVzorakNode base_model = m.UlohaVzorakNode
show_in_index = True show_in_index = True
@admin.register(m.TextNode) @admin.register(m.TextNode)
class TextNodeAdmin(PolymorphicChildModelAdmin): class TextNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.TextNode base_model = m.TextNode
show_in_index = True show_in_index = True
@admin.register(m.CastNode) @admin.register(m.CastNode)
class TextNodeAdmin(PolymorphicChildModelAdmin): class TextNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.CastNode base_model = m.CastNode
show_in_index = True show_in_index = True
fields = ('nadpis',) fields = ('nadpis',)
@admin.register(m.OrgTextNode) @admin.register(m.OrgTextNode)
class TextNodeAdmin(PolymorphicChildModelAdmin): class TextNodeAdmin(PolymorphicChildModelAdmin):
base_model = m.OrgTextNode base_model = m.OrgTextNode
show_in_index = True show_in_index = True

4
treenode/permissions.py

@ -2,6 +2,6 @@ from rest_framework.permissions import BasePermission
class AllowWrite(BasePermission): class AllowWrite(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
return request.user.has_perm('auth.org') return request.user.has_perm('auth.org')

24
various/autentizace/utils.py

@ -6,16 +6,16 @@ from django.utils.http import urlsafe_base64_encode
def posli_reset_hesla(u, request=None): def posli_reset_hesla(u, request=None):
uid = urlsafe_base64_encode(force_bytes(u.pk)) uid = urlsafe_base64_encode(force_bytes(u.pk))
token = PasswordResetTokenGenerator().make_token(u) token = PasswordResetTokenGenerator().make_token(u)
url = "https://%s%s" % ( url = "https://%s%s" % (
str(get_current_site(request)), str(get_current_site(request)),
str(reverse_lazy("reset_password_confirm", args=[uid, token])) str(reverse_lazy("reset_password_confirm", args=[uid, token]))
) )
u.email_user( u.email_user(
subject="Vítej mezi řešiteli M&M!", subject="Vítej mezi řešiteli M&M!",
message="""Milý řešiteli, milá řešitelko, message="""Milý řešiteli, milá řešitelko,
tvůj e-mail byl právě zaregistrován na mam.matfyz.cz. Heslo si prosím nastav na: %s tvůj e-mail byl právě zaregistrován na mam.matfyz.cz. Heslo si prosím nastav na: %s
@ -26,6 +26,6 @@ Organizátoři M&M
Tento e-mail byl vygenerován automaticky, chceš-li nás kontaktovat, napiš nám na adresu mam@matfyz.cz. Tento e-mail byl vygenerován automaticky, chceš-li nás kontaktovat, napiš nám na adresu mam@matfyz.cz.
""" % url, """ % url,
# TODO: templates/autentizace a django/contrib/auth/forms.py říkají, jak na to lépe # TODO: templates/autentizace a django/contrib/auth/forms.py říkají, jak na to lépe
from_email="registrace@mam.mff.cuni.cz", # FIXME: Chceme to mít radši tady, nebo v nastavení? from_email="registrace@mam.mff.cuni.cz", # FIXME: Chceme to mít radši tady, nebo v nastavení?
) )

8
various/log_filters.py

@ -2,10 +2,10 @@ from logging import Filter, INFO
from django.urls import reverse from django.urls import reverse
class Http404AsInfoFilter(Filter): class Http404AsInfoFilter(Filter):
def filter(self, record): def filter(self, record):
if record.name == 'django.request' and record.status_code == 404: if record.name == 'django.request' and record.status_code == 404:
record.levelno = INFO record.levelno = INFO
return 1 # Keep the log record return 1 # Keep the log record
class StripSensitiveFormDataFilter(Filter): class StripSensitiveFormDataFilter(Filter):
def filter(self, record): def filter(self, record):

0
various/templatetags/__init__.py

48
various/templatetags/mail.py

@ -0,0 +1,48 @@
from django import template
from django.utils.safestring import mark_safe
from urllib.request import quote as urlencode
register = template.Library()
@register.simple_tag
def mailurl(*, subject=None, body=None, to=[], cc=[], bcc=[]):
"""Tag na vytváření správně zakódované mailto: adresy
Ref: RFC 6068, <https://en.wikipedia.org/wiki/Mailto>"""
if isinstance(to, str):
to = [to]
if isinstance(cc, str):
cc = [cc]
if isinstance(bcc, str):
bcc = [bcc]
assert isinstance(to, list)
assert isinstance(cc, list)
assert isinstance(bcc, list)
# FIXME: adresa není správně zakódovaná, rozbije se to na adresách s divnými znaky
parts = [
f'mailto:{str.join(",", to)}',
]
if len(to) + len(cc) + len(bcc) < 1:
raise ValueError('Cannot mail to empty set of people')
if subject:
parts.append(f'subject={urlencode(subject)}')
if body:
parts.append(f'body={urlencode(body)}')
if len(cc) > 0:
parts.append(f'cc={str.join(",", cc)}')
if len(bcc) > 0:
parts.append(f'bcc={str.join(",", bcc)}')
if len(parts) > 1:
url = parts[0] + '?' + str.join('&', parts[1:])
else:
url = parts[0]
return url
@register.simple_tag
def maillink(text, subject=None, body=None, to=[], cc=[], bcc=[], attrs=None):
url = mailurl(subject=subject, body=body, to=to, cc=cc, bcc=bcc)
if not attrs: attrs = ''
mezera = ' '*bool(attrs)
full_link = f'<a href="{url}"{mezera}{attrs}>{text}</a>'
return mark_safe(full_link)

59
various/tests.py

@ -1,3 +1,60 @@
from django.test import TestCase from django.test import TestCase
# TODO: Možná vyrobit separátní soubory v tests/… než mít všechny testy v jednom souboru?
from various.templatetags.mail import maillink, mailurl
# Create your tests here. class MailTagsTest(TestCase):
"""Testuje template tagy ohledně mailů."""
def test_maillink(self):
# Tohle nedává smysl dělit do víc funkcí, bylo by v nich víc boilerplatu než užitečného kódu.
self.assertEquals(maillink('Hello', to='some@body.test'), r'<a href="mailto:some@body.test">Hello</a>')
self.assertEquals(maillink('Hello', to=['some@body.test']), r'<a href="mailto:some@body.test">Hello</a>')
self.assertEquals(
maillink('Hello', to=['alice@test.test', 'bob@jinde.test']),
r'<a href="mailto:alice@test.test,bob@jinde.test">Hello</a>',
)
self.assertEquals(
maillink('Hello', to='some@body.test', attrs='class="trida" id="id"'),
r'<a href="mailto:some@body.test" class="trida" id="id">Hello</a>',
)
# Následující test toho testuje moc zároveň, měly by předcházet dedikované testy… (kašlu na ně :-P)
self.assertEquals(
maillink('Text odkazu', to='prijemce@wtf.test', subject="Předmět", body="Čau"),
r'<a href="mailto:prijemce@wtf.test?subject=P%C5%99edm%C4%9Bt&body=%C4%8Cau">Text odkazu</a>',
)
self.assertRaises(ValueError, lambda: maillink('Nemám příjemce'))
self.assertRaises(TypeError, lambda: maillink()) # Nemá text, takže to shodí python
def test_mailurl(self):
self.assertEquals(mailurl(to='some@body.test'), r'mailto:some@body.test')
self.assertEquals(mailurl(to=['some@body.test']), r'mailto:some@body.test')
self.assertEquals(mailurl(to=['alice@test.test', 'bob@jinde.test']), r'mailto:alice@test.test,bob@jinde.test')
self.assertEquals(
mailurl(to='some@body.test', body='Tělo', subject='Předmět'),
r'mailto:some@body.test?subject=P%C5%99edm%C4%9Bt&body=T%C4%9Blo',
)
self.assertRaises(ValueError, lambda: mailurl())
def test_render_in_template(self):
# Pomocná funkce: vykreslí template do stringu
# Ref: https://stackoverflow.com/a/1690879
def render_template(template, context=None):
from django.template import Template, Context
context = context or {}
context = Context(context)
return Template(template).render(context)
template = (
r'{% load mail %}'
# TODO: Vyzkoušet i víc adresátů. (Nepamatuji si z hlavy syntaxi…)
r'{% maillink "Text" to="alice@test.test" subject="Oprava řešení" %}'
)
self.assertEquals(
render_template(template),
r'<a href="mailto:alice@test.test?subject=Oprava%20%C5%99e%C5%A1en%C3%AD">Text</a>',
)
mailurltemplate = (
r'{% load mail %}'
r'{% mailurl to="alice@test.test" subject="Čau Alice" %}'
)
self.assertEquals(render_template(mailurltemplate), r'mailto:alice@test.test?subject=%C4%8Cau%20Alice')

4
vysledkovky/utils.py

@ -1,6 +1,6 @@
import abc import abc
from functools import cached_property from functools import cached_property
from typing import Union # TODO: s pythonem 3.10 přepsat na '|' from typing import Union, Iterable # TODO: s pythonem 3.10 přepsat na '|'
import seminar.models as m import seminar.models as m
from django.db.models import Q, Sum from django.db.models import Q, Sum
@ -22,7 +22,7 @@ def body_resitelu(
do: m.Deadline = None, do: m.Deadline = None,
od: m.Deadline = None, od: m.Deadline = None,
jen_verejne: bool = True, jen_verejne: bool = True,
resitele=None, resitele: Iterable[m.Resitel] = None,
null=0 # Výchozí hodnota, pokud pro daného řešitele nejsou body null=0 # Výchozí hodnota, pokud pro daného řešitele nejsou body
) -> dict[int, int]: ) -> dict[int, int]:
filtr = Q() filtr = Q()

Loading…
Cancel
Save