├── .docker
├── Dockerfile
├── config_isolate
├── dev-db-data.sql
└── entrypoint.sh
├── .dockerignore
├── .gitattributes
├── .github
└── workflows
│ └── build-docker.yml
├── .gitignore
├── LICENSE
├── README.md
├── app.py
├── auth.py
├── config.py.example
├── db.py
├── doc
└── programming.md
├── docker-compose.yaml
├── encryption.py
├── endpoint
├── __init__.py
├── achievement.py
├── admin
│ ├── __init__.py
│ ├── achievementGrant.py
│ ├── corrections.py
│ ├── correctionsEmail.py
│ ├── correctionsInfo.py
│ ├── correctionsPublish.py
│ ├── diploma.py
│ ├── email.py
│ ├── evalCode.py
│ ├── evaluations.py
│ ├── execs.py
│ ├── instanceConfig.py
│ ├── monitoringDashboard.py
│ ├── submFilesEval.py
│ ├── submFilesTask.py
│ ├── task.py
│ ├── taskDeploy.py
│ ├── taskMerge.py
│ ├── userExport.py
│ └── waveDiff.py
├── article.py
├── content.py
├── csp.py
├── diploma.py
├── feedback_email.py
├── feedback_task.py
├── image.py
├── module.py
├── oauth2.py
├── post.py
├── profile.py
├── registration.py
├── robots.py
├── runcode.py
├── task.py
├── thread.py
├── unsubscribe.py
├── user.py
├── wave.py
└── year.py
├── gunicorn_cfg.py.example
├── init-makedirs.sh
├── model
├── __init__.py
├── achievement.py
├── active_orgs.py
├── article.py
├── audit_log.py
├── cache.py
├── config.py
├── diploma.py
├── evaluation.py
├── feedback.py
├── feedback_recipients.py
├── mail_easteregg.py
├── module.py
├── module_custom.py
├── post.py
├── prerequisite.py
├── profile.py
├── programming.py
├── submitted.py
├── task.py
├── text.py
├── thread.py
├── token.py
├── user.py
├── user_achievement.py
├── user_notify.py
├── wave.py
└── year.py
├── requirements.txt
├── runner
├── util
├── __init__.py
├── achievement.py
├── admin
│ ├── __init__.py
│ ├── task.py
│ ├── taskDeploy.py
│ ├── taskMerge.py
│ └── waveDiff.py
├── auth.py
├── cache.py
├── config.py
├── content.py
├── correction.py
├── correctionInfo.py
├── feedback.py
├── git.py
├── lock.py
├── logger.py
├── mail.py
├── module.py
├── post.py
├── prerequisite.py
├── profile.py
├── programming.py
├── quiz.py
├── sortable.py
├── submissions.py
├── task.py
├── text.py
├── thread.py
├── user.py
├── user_notify.py
├── wave.py
└── year.py
└── utils
├── generate_org_performance_report.py
├── import_tasks.py
├── upload-diplomas.py
├── use-named-requirements.py
└── util_login.py
/.docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11-bookworm
2 | ENV DIR_BE /opt/web-backend
3 |
4 | RUN mkdir $DIR_BE
5 | WORKDIR $DIR_BE
6 | EXPOSE 3030
7 |
8 | # install and setup required software
9 | RUN wget -O - https://deb.adamhlavacek.com/pub.gpg | apt-key add - \
10 | && echo "deb https://deb.adamhlavacek.com ./" >> /etc/apt/sources.list \
11 | && apt-get update \
12 | && apt-get install -y pandoc acl sudo curl isolate bindfs libpython3.7 libcap2 sqlite3 python3-distutils \
13 | && sudo apt-get clean \
14 | && pip3 install virtualenv --no-cache-dir \
15 | && virtualenv -p python3 ksi-py3-venv \
16 | && mkdir /opt/etc \
17 | && echo 'SQL_ALCHEMY_URI = "sqlite:////var/ksi-be/db.sqlite"' > config.py \
18 | && echo 'ENCRYPTION_KEY = "AGE-SECRET-KEY-1TLU9YP9V55UDN4LE9Q0QYDH6ZXDA2JWRCZTHQTWPTYV7U800PFFS3PDNS9"' >> config.py
19 |
20 | ADD requirements.txt $DIR_BE/requirements.txt
21 | RUN bash -c 'cd $DIR_BE && source ksi-py3-venv/bin/activate && pip install --no-cache-dir -r requirements.txt'
22 |
23 | ADD . $DIR_BE/
24 |
25 | RUN bash -c 'cd $DIR_BE && ./init-makedirs.sh' \
26 | && bash -c 'cd $DIR_BE && chmod +x .docker/entrypoint.sh' \
27 | && mkdir /var/ksi-be.ro \
28 | && mkdir /var/ksi-be \
29 | && mkdir /var/ksi-seminar.git \
30 | && mv "$DIR_BE/.docker/config_isolate" /usr/local/etc/isolate \
31 | && chmod 644 /usr/local/etc/isolate \
32 | && chmod u+s /usr/bin/isolate \
33 | && chown root:root /usr/local/etc/isolate \
34 | && sed -e 's/READ COMMITTED",/SERIALIZABLE", connect_args={"check_same_thread": False},/' -i db.py \
35 | && sed -e 's/127.0.0.1/0.0.0.0/' -i gunicorn_cfg.py.example \
36 | && sed -e 's/CURRENT_TIMESTAMP + INTERVAL 1 SECOND/datetime("now", "+1 seconds")/' -i endpoint/post.py
37 |
38 | RUN useradd -mru 999 ksi
39 |
40 | HEALTHCHECK --interval=300s --start-period=180s CMD curl --silent --fail http://127.0.0.1:3030/years || exit 1
41 | ENTRYPOINT ["/bin/bash"]
42 | CMD ["./.docker/entrypoint.sh"]
43 |
--------------------------------------------------------------------------------
/.docker/config_isolate:
--------------------------------------------------------------------------------
1 | # This is a configuration file for Isolate
2 |
3 | # All sandboxes are created under this directory.
4 | # To avoid symlink attacks, this directory and all its ancestors
5 | # must be writeable only to root.
6 | box_root = /tmp/box
7 |
8 | # Root of the control group hierarchy
9 | cg_root = /sys/fs/cgroup
10 |
11 | # If the following variable is defined, the per-box cgroups
12 | # are created as sub-groups of the named cgroup
13 | #cg_parent = boxes
14 |
15 | # Block of UIDs and GIDs reserved for sandboxes
16 | first_uid = 60000
17 | first_gid = 60000
18 | num_boxes = 100000
19 |
20 | # Per-box settings of the set of allowed CPUs and NUMA nodes
21 | # (see linux/Documentation/cgroups/cpusets.txt for precise syntax)
22 |
23 | #box0.cpus = 4-7
24 | #box0.mems = 1
25 |
--------------------------------------------------------------------------------
/.docker/dev-db-data.sql:
--------------------------------------------------------------------------------
1 | BEGIN TRANSACTION;
2 | INSERT INTO "years" ("id","year","sealed","point_pad") VALUES (1,'2024',0,0);
3 | INSERT INTO "users" ("id","email","github","discord","phone","first_name","nick_name","last_name","sex","password","short_info","profile_picture","role","enabled","registered","last_logged_in") VALUES (1,'admin@localhost',NULL,NULL,NULL,'Admin','','Developer','male','$2b$12$8msl2crG4so1cwDiDeFkoeNTwnSLHSusmkbuNintOov0dLb9ihuKa','',NULL,'admin',1,'2000-01-01 00:00:00.000000','2024-08-10 15:43:25.227080');
4 | INSERT INTO "tasks" ("id","title","author","co_author","wave","prerequisite","intro","body","solution","thread","picture_base","time_created","time_deadline","evaluation_public","git_path","git_branch","git_commit","git_pull_id","deploy_date","deploy_status","eval_comment") VALUES (1,'First Task Name',1,NULL,1,NULL,'This text is shown in a popup on hover.','
Content of the first task.
','Solution.
5 | H2
6 | More solution.
7 | ',1,NULL,'2024-08-10 15:43:35.120071','2099-01-01 23:59:59.000000',0,'2024/vlna1/uloha_01_first_task','master','7bef31b57bb0d57167fb86a998218be35b74782b',NULL,'2024-08-10 16:22:05.637466','done','');
8 | INSERT INTO "waves" ("id","year","index","caption","garant","time_published") VALUES (1,1,1,'First Wave',1,'2098-12-31 23:00:00.000000');
9 | INSERT INTO "profiles" ("user_id","addr_street","addr_city","addr_zip","addr_country","school_name","school_street","school_city","school_zip","school_country","school_finish","tshirt_size","referral") VALUES (1,'Street 1','City','123','cz','Uni','Street Uni','City Uni','456','cz',2000,'NA',NULL);
10 | INSERT INTO "articles" ("id","author","title","body","picture","time_created","published","year","resource") VALUES (1,1,'Welcome to the test site!','This is a test site with a very simple testing content.
',NULL,'1999-12-31 23:00:00.000000',1,1,'articles/1');
11 | INSERT INTO "threads" ("id","title","public","year") VALUES (1,'First Task Name',1,1);
12 | INSERT INTO "modules" ("id","task","type","name","description","max_points","autocorrect","order","bonus","custom","action","data") VALUES (1,1,'general','File submission module','Feel free to try it out
13 | ',10,0,0,0,0,NULL,'{}'),
14 | (2,1,'programming','Programming module','Your task is to write a function that check if a number is odd or even.
15 | ',2,1,1,0,0,NULL,'{
16 | "programming": {
17 | "default_code": "# Implement this function:\ndef is_odd(x: int) -> bool:\n pass\n\n# Example results:\nprint(is_odd(1)) # True\nprint(is_odd(2)) # False\nprint(is_odd(3)) # True\nprint(is_odd(4)) # False\nprint(is_odd(5)) # True\n",
18 | "version": "2.0",
19 | "merge_script": "data/modules/2/merge",
20 | "stdin": "data/modules/2/stdin.txt",
21 | "check_script": "data/modules/2/eval"
22 | }
23 | }');
24 | INSERT INTO "active_orgs" ("org","year") VALUES (1,1);
25 | INSERT INTO "users_notify" ("user","auth_token","notify_eval","notify_response","notify_ksi","notify_events") VALUES (1,'a4bcf1a180d5cd5a3e1a6d04df757537652f5448',1,1,1,1);
26 | INSERT INTO `config` (`key`, `value`, `secret`) VALUES
27 | ('backend_url', 'http://localhost:3030', 0),
28 | ('discord_invite_link', NULL, 0),
29 | ('github_api_org_url', NULL, 0),
30 | ('github_token', NULL, 1),
31 | ('ksi_conf', 'all-organizers-group@localhost', 0),
32 | ('mail_sender', NULL, 0),
33 | ('mail_sign', 'Good luck!
Testing Seminar of Informatics', 0),
34 | ('monitoring_dashboard_url', NULL, 0),
35 | ('return_path', 'mail-error@localhost', 0),
36 | ('seminar_repo', 'seminar', 0),
37 | ('successful_participant_trophy_id', '-1', 0),
38 | ('successful_participant_percentage', '60', 0),
39 | ('webhook_discord_username_change', NULL, 0),
40 | ('web_url_admin', NULL, 0),
41 | ('mail_subject_prefix', '[TEST SEMINAR]', 0),
42 | ('seminar_name', 'Testing Seminar of Informatics', 0),
43 | ('seminar_name_short', 'TSI', 0),
44 | ('mail_registration_welcome', 'testing seminar of informatics.', 0),
45 | ('box_prefix_id', '1', 0),
46 | ('access_control_allow_origin', '*', 0),
47 | ('web_url', 'http://localhost:8080', 0),
48 | ('discord_bot_secret', NULL, 1);
49 | COMMIT;
50 |
--------------------------------------------------------------------------------
/.docker/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | cd "$(realpath "$(dirname "$0")")/.." || { echo "ERR: Cannot cd to script dir"; exit 1; }
3 |
4 | DIR_BE="/opt/web-backend"
5 |
6 | if [ "$SEMINAR_GIT_URL" == "::local::" ]; then
7 | SEMINAR_GIT_URL=""
8 | fi
9 |
10 | bindfs /etc /opt/etc || { echo "ERR: Bind mount for isolate"; exit 1; }
11 | bindfs /opt/data "$DIR_BE/data" -u ksi -g ksi -o nonempty --create-for-user=1000 || { echo "ERR: Bind mount for data"; exit 1; }
12 | bindfs /opt/database /var/ksi-be/ -u ksi -g ksi --create-for-user=1000 || { echo "ERR: Bind mount for database dir"; exit 1; }
13 | if [ -z "$SEMINAR_GIT_URL" ]; then
14 | bindfs /opt/seminar.git /var/ksi-seminar.git/ -u ksi -g ksi --create-for-user=1000 || { echo "ERR: Bind mount for seminar dir"; exit 1; }
15 | fi
16 |
17 | bash init-makedirs.sh || { echo "ERR: Cannot create directories"; exit 1; }
18 |
19 | # Copy sample config if not exists
20 | if [ ! -f "$DIR_BE/gunicorn_cfg.py" ]; then
21 | echo "[*] Copying sample gunicorn config..."
22 | cp "$DIR_BE/gunicorn_cfg.py.example" "$DIR_BE/gunicorn_cfg.py" || { echo "ERR: Cannot copy gunicorn config"; exit 1; }
23 | fi
24 |
25 | # Setup git name and email if not yet set
26 | if [ -z "$(git config --global user.name)" ]; then
27 | echo "[*] Setting up git user name..."
28 | sudo -Hu ksi git config --global user.name "ksi-backend" || { echo "ERR: Cannot set git user name"; exit 1; }
29 | fi
30 |
31 | # Check if git user email is set
32 | if [ -z "$(git config --global user.email)" ]; then
33 | echo "[*] Setting up git user email..."
34 | sudo -Hu ksi git config --global user.email "ksi-backend@localhost" || { echo "ERR: Cannot set git user email"; exit 1; }
35 | fi
36 |
37 | # Copy module lib if directory is empty
38 | if [ ! -d "$DIR_BE/data/module_lib" ] || [ ! "$(ls -A "$DIR_BE/data/module_lib")" ]; then
39 | echo "[*] Setting up module lib for the first time..."
40 | git clone https://github.com/fi-ksi/module_lib.git "$DIR_BE/data/module_lib" || { echo "ERR: Cannot copy module lib"; rm -rf "$DIR_BE/data/module_lib"; exit 1; }
41 | fi
42 |
43 | # Create seminar repo if not exists
44 | if [ ! -d "$DIR_BE/data/seminar" ] || [ ! "$(ls -A "$DIR_BE/data/seminar")" ]; then
45 | echo "[*] Setting up seminar repo for the first time..."
46 |
47 | if [ "$SEMINAR_GIT_URL" ]; then
48 | echo "[*] Cloning seminar repo as SEMINAR_GIT_URL is set ...." &&
49 | sudo -Hu ksi git clone "$SEMINAR_GIT_URL" "$DIR_BE/data/seminar" &&
50 | export SEMINAR_GIT_URL="" || # prevent leaking the URL
51 | { echo "ERR: Prepare first seminar"; rm -rf "$DIR_BE/data/seminar"; exit 1; }
52 | else
53 | echo "[*] Creating new seminar repo as SEMINAR_GIT_URL is NOT set ...." &&
54 | sudo -Hu ksi git clone https://github.com/esoadamo/seminar-template.git "$DIR_BE/data/seminar" &&
55 | rm -rf "$DIR_BE/data/seminar/.git" &&
56 | sudo -Hu ksi git config --global init.defaultBranch master &&
57 | sudo -Hu ksi git init --bare "/var/ksi-seminar.git/" &&
58 | pushd "$DIR_BE/data/seminar" &&
59 | sudo -Hu ksi git init . &&
60 | sudo -Hu ksi git remote add origin "/var/ksi-seminar.git/" &&
61 | sudo -Hu ksi git add . &&
62 | sudo -Hu ksi git status &&
63 | sudo -Hu ksi git commit -m "Initial commit" &&
64 | sudo -Hu ksi git push -u origin master &&
65 | popd || { echo "ERR: Prepare first seminar"; rm -rf "$DIR_BE/data/seminar"; exit 1; }
66 | fi
67 | fi
68 |
69 | # create database if not exists
70 | if [ ! -f '/var/ksi-be/db.sqlite' ]; then
71 | echo "Database does not exist yet, creating all tables" &&
72 | cp app.py gen-db.py &&
73 | sed -e 's/# model/model/' -i gen-db.py &&
74 | sudo -Hu ksi bash -c 'source ksi-py3-venv/bin/activate && python gen-db.py' &&
75 | rm gen-db.py &&
76 | sqlite3 "/var/ksi-be/db.sqlite" < "$DIR_BE/.docker/dev-db-data.sql" &&
77 | echo "Database created" || { echo "ERR: Cannot start the DB"; rm -f '/var/ksi-be/db.sqlite'; exit 1; }
78 | fi
79 |
80 | # Start the backend
81 | sudo -Hu ksi bash -c 'source ksi-py3-venv/bin/activate && gunicorn -c gunicorn_cfg.py app:api'
82 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | __pycache__
3 | *.pyc
4 | .idea
5 | data/
6 | config.py
7 | ksi-py3-venv
8 | .docker/build.sh
9 | .docker/Dockerfile
10 | .docker/data
11 | docker-compose.yaml
12 | gunicorn_cfg.py
13 | .git
14 | .gitignore
15 | .venv
16 | venv
17 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Basically all the files should use LF only
2 | * text=auto
3 | *.py text
4 |
5 | ksi-backend merge=ours
6 |
--------------------------------------------------------------------------------
/.github/workflows/build-docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker Compose Build and Push
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - master
8 |
9 | jobs:
10 | build_and_push:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Check out the repository, including submodules
15 | uses: actions/checkout@v3
16 | with:
17 | submodules: recursive
18 | path: web
19 |
20 | - name: Set up Docker Buildx
21 | uses: docker/setup-buildx-action@v3
22 |
23 | - name: Log in to Docker Hub
24 | uses: docker/login-action@v3
25 | with:
26 | username: ${{ secrets.DOCKER_USERNAME }}
27 | password: ${{ secrets.DOCKER_PASSWORD }}
28 |
29 | - name: Build and push Docker images
30 | working-directory: ./web
31 | run: |
32 | docker compose build
33 | docker compose push
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .DS_Store
3 | __pycache__
4 | *.pyc
5 | data/
6 | config.py
7 | ksi-py3-venv
8 | .run/
9 | */.vscode
10 | .docker/data
11 | gunicorn_cfg.py
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015-2016 Jan Horacek
2 | Copyright (c) 2015 Jan Stourac
3 | Copyright (c) 2015 Dominik Gmiterko
4 | Copyright (c) 2015 Jan Mrazek
5 | Copyright (c) 2015 Martin Polednik
6 | Copyright (c) 2015 Eva Smijakova
7 |
8 | Permission is hereby granted, free of charge, to any person
9 | obtaining a copy of this software and associated documentation
10 | files (the "Software"), to deal in the Software without
11 | restriction, including without limitation the rights to use,
12 | copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | copies of the Software, and to permit persons to whom the
14 | Software is furnished to do so, subject to the following
15 | conditions:
16 |
17 | The above copyright notice and this permission notice shall be
18 | included in all copies or substantial portions of the Software.
19 |
20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | OTHER DEALINGS IN THE SOFTWARE.
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Backend for Online Seminar of Informatics
2 |
3 | [ksi.fi.muni.cz](https://ksi.fi.muni.cz/)
4 |
5 | ## Running with docker
6 |
7 | The backend can be run inside a docker container for testing purposes. To build the image, run:
8 |
9 | ```bash
10 | docker-compose up --build
11 | ```
12 |
13 | This will build the image and start the container, together with development versions of the frontend.
14 | - backend will be running at [http://localhost:3030](http://localhost:3030)
15 | - frontend will be running at [http://localhost:4201](http://localhost:4201)
16 | - old frontend will be running at [http://localhost:8080](http://localhost:8080)
17 |
18 | The master account is `admin@localhost` with password `change-me`.
19 |
20 | The backend is created together with a sample [seminar repository](https://github.com/fi-ksi/seminar-template).
21 | To use the repository, you must clone it locally after starting the container:
22 |
23 | ```bash
24 | git clone .docker/data/seminar.git seminar-dev
25 | ```
26 |
27 | The backend will automatically push and pull from the repository in the container, you can work with your own clone.
28 |
29 | ## Running manually
30 |
31 | Running manually is discouraged, as it requires a lot of setup. If you still want to run the backend manually, follow the instructions below.
32 |
33 | ### Software needed
34 |
35 | * Python 3.7+
36 | * virtualenv
37 | * packages from `requirements.txt`
38 | * [isolate](https://github.com/ioi/isolate)
39 |
40 | ### Installation
41 |
42 | 1. Clone this repository.
43 | 2. Run `init-makedirs.sh`.
44 | 3. Install virtualenv & packages into `ksi-py3-venv` directory.
45 | ```
46 | virtualenv -p python3 ksi-py3-venv
47 | source ksi-py3-venv/bin/activate
48 | pip3 install -r requirements.txt
49 | ```
50 | 4. Enter db url into `config.py` file. Format is the same as specified in `config.py.dist`
51 | 5. Uncomment part of the `app.py`, which creates database structure.
52 | 6. Run the server, comment the database-create-section in `run.py`
53 | 7. Install `isolate` with box directory `/tmp/box`.
54 | 8. Bind-mount `/etc` directory to `/opt/etc` (this is required for sandbox to
55 | work):
56 | ```
57 | $ mount --bind /etc /opt/etc
58 | ```
59 | Do not forget to add it to `/etc/fstab`.
60 | 9. Optional: make `/tmp` tmpfs.
61 | 10. Optional: ensure the server will be started after system boots up
62 | (run `./runner start`).
63 |
64 | ### Server control
65 |
66 | * To start server run: `./runner start`.
67 | * To stop server run: `./runner stop`.
68 | * The `runner` script must be executed in server`s root directory.
69 | * Logs are stored in `/var/log/gunicorn/*`.
70 |
--------------------------------------------------------------------------------
/auth.py:
--------------------------------------------------------------------------------
1 | import bcrypt
2 | import secrets
3 |
4 | from db import session
5 | import model
6 | import datetime
7 |
8 | TOKEN_LENGTH = 40
9 |
10 |
11 | def _generate_token():
12 | return secrets.token_urlsafe(TOKEN_LENGTH)
13 |
14 |
15 | def get_hashed_password(plain_text_password: str) -> str:
16 | return bcrypt.hashpw(plain_text_password.encode('utf-8'), bcrypt.gensalt()).decode('ascii')
17 |
18 |
19 | def check_password(plain_text_password: str, hashed_password: str) -> bool:
20 | plain_bytes = plain_text_password.encode('utf8')
21 | hash_bytes = hashed_password.encode('utf8')
22 |
23 | return bcrypt.checkpw(plain_bytes, hash_bytes)
24 |
25 |
26 | class OAuth2Token(object):
27 | def __init__(self, client_id: model.User):
28 | self.value = _generate_token()
29 | self.expire = datetime.datetime.utcnow() + datetime.timedelta(hours=1)
30 | self.kind = 'Bearer'
31 | self.refresh = _generate_token()
32 |
33 | token = model.Token()
34 | token.access_token = self.value
35 | token.expire = self.expire
36 | token.refresh_token = self.refresh
37 | token.user = client_id.id
38 |
39 | try:
40 | client_id.last_logged_in = datetime.datetime.utcnow()
41 | session.add(token)
42 | session.commit()
43 | except:
44 | session.rollback()
45 | raise
46 |
47 | @property
48 | def data(self):
49 | return {
50 | 'access_token': self.value,
51 | 'token_type': self.kind,
52 | 'expires_in': int((self.expire-datetime.datetime.utcnow()).
53 | total_seconds()),
54 | 'refresh_token': self.refresh
55 | }
56 |
--------------------------------------------------------------------------------
/config.py.example:
--------------------------------------------------------------------------------
1 | SQL_ALCHEMY_URI = 'mysql://username:password@server/db_name?charset=utf8mb4'
2 | # Generated using `age-keygen` command, https://github.com/FiloSottile/age
3 | ENCRYPTION_KEY = 'AGE-SECRET-KEY-000000000000000000000000'
4 |
--------------------------------------------------------------------------------
/db.py:
--------------------------------------------------------------------------------
1 | import sqlalchemy
2 | from sqlalchemy.orm import sessionmaker
3 |
4 | import config
5 |
6 |
7 | engine = sqlalchemy.create_engine(config.SQL_ALCHEMY_URI,
8 | isolation_level="READ COMMITTED",
9 | pool_recycle=3600)
10 | _session = sessionmaker(bind=engine)
11 | session = _session()
12 |
--------------------------------------------------------------------------------
/doc/programming.md:
--------------------------------------------------------------------------------
1 | # Priprava programovaciho modulu #
2 |
3 | Kazdy programovaci modul ma 2 rozdilne vetve "zivota". Prvni z nich je **spusteni kodu**, ktere ulohu nevyhodnocuje a pouze umozni uzivateli si spustit kod a neco vygenerovat. Vystupem teto vetve je 1) `stdout` sandboxu v pripade, ze dobehl s nulovym navratovym kodem nebo `stderr` v pripade selhani, 2) v nekterych ulohach obrazek. V druhe vetvi, ktera jiz ulohu **vyhodnocuje** je ignorovano generovani obrazku a uzivateli je vracena pouze informace *dobre*/*spatne*.
4 |
5 | Pro spravne pripraveni programovaci modulu, je nutne vytvoritt skript pro kazdy krok workflow (volitelne i post-triggeru) a spravne zpracovat predavane parametry.
6 |
7 | ## 1. Merge skript ##
8 |
9 | **Vstup:** soubor s uzivatelskym kodem
10 |
11 | **Vystup:** skript pripraveny ke spusteni
12 |
13 | **Parametry:** ` `
14 |
15 | **Sloupce v DB:** `merge_script`
16 |
17 | V tomto kroku by melo dojit k doplneni uzivatelskeho kodu o nezbytne hlavicky, paticky, atd. a jeho ulozeni do specifikovaneho souboru. Tento skript by v idealnim pripade mel vzdy skoncit s nulovym navratovym kodem bez chyb. Pokud by se nejaka chyba vyskytla a proces skoncil s nenulovym kodem, je v pripade spusteni kodu jeho spousteni ukonceno s chybovym kodem *1* a v pripade vyhodnoceni je udeleno 0 bodu.
18 |
19 | **Ukazka:**
20 | ```
21 | import sys
22 |
23 | infile = open(sys.argv[1], 'r')
24 | outfile = open(sys.argv[2], 'w')
25 |
26 | outfile.write('print \'This is header\'\n')
27 | outfile.write(infile.read())
28 | outfile.write('print \'This is footer\'\n')
29 | outfile.close()
30 | ```
31 |
32 | ## 2. Spusteni v sandboxu ##
33 | **Vstup:** soubor s kodem, umisteni sandboxu, argumenty, stdin, timeout
34 |
35 | **Vystup:** stdout, stderr a pripadne ulozene soubor
36 |
37 | **Sloupce v DB:** `stdin`, `arguments`, `timeout`
38 |
39 | V tomto kroku neni nutne pripravit zadny skript, protoze sandbox je predpripraveny a nepotrebuje zadnou specialni obsluhu. Proto je treba pouze pripravit soubor reprezentujici `stdin` (klasicky textovy soubor) a seznam argumentu, ktere budou predany spoustenemu skriptu. Aby se usnadnilo jejich zpracovani, je nutne je do DB zadat ve formatu "python-like" seznamu (tim se poresi jejich spravne predani).
40 |
41 | Dale je mozne take specifikovat limity, ktere bude mit sandbox tak, aby nemohlo dojit k umyslnemu pretizeni stroje.
42 |
43 | Velmi dulezita je take slozka sandboxu, ktera bude namapovana jako `/tmp` adresar spusteneho skriptu a pri spravnem nastaveni PyPy by mohla byt i zapisovatelna pro spusteny proces. Z toho duvodu by mela byt pouzivana pro **veskere vystupy** uzivatelskeho skriptu!
44 |
45 | **Ukazka argumentu:**
46 | `[ 'jeden', 'dva', 's mezerou', '--pomlckou', 'a', '--rovnitkem=hodnota' ]`
47 |
48 | ## 3. Post-trigger ##
49 | **Vstup:** slozka sandboxu
50 |
51 | **Vystup:** obrazek, JSON informujici o jeho jmenu
52 |
53 | **Argumenty:** ``
54 |
55 | **Sloupce v DB:** `trigger_script`
56 |
57 | Toto je jediny nepovinny krok vyhodnoceni a jeho cilem je poskytnout moznost vygenerovat obrazek/cokoliv. Sva data muze cerpat ze slozky sandboxu (tzn. je nutna synchronizace s generujici patickou) a vystupem by krome souboru mel byt take JSON vypsany na `stdout`, ktery obsahuje o nich informace.
58 |
59 | Take pozor na to, ze tento krok je volan pouze pri *spousteni kodu* a nikoliv jeho vyhodnocovani!i
60 |
61 | **POZOR:** Jelikoz je aktualne pozadavek pouze na jeden obrazek, tak je bran v potaz vzdycky pouze prvni soubor ve vystupnim JSONu a ostatni jsou pripadne zahazovany!
62 |
63 | **Priklad vystupniho JSONu:**
64 | ```
65 | {
66 | 'attachments': [ 'out.jpg', ...]
67 | }
68 | ```
69 |
70 | ## 4. Vyhodnoceni ulohy ##
71 |
72 | **Vstup:** stdout sandboxu, slozka sandboxu
73 |
74 | **Vystup:** -
75 |
76 | **Argumenty:** ` `
77 |
78 | **Sloupce v DB:** `check_script`
79 |
80 | Skript zajistujici vyhodnoceni ulohy. Na zaklade jeho navratoveho kodu je rozhodnuto o spravnosti reseni (0 -> OK, jinak NOK). K dispozici ma jak celou slozku sandboxu (se vsemi soubory), tak stdout sandboxu, ktery muze obsahovat potrebne vystupy.
81 |
82 | Tato cast se vola pouze pri vyhodnocovani ulohy. Pri pouhem *spusteni kodu* neni provedena.
83 |
84 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | ksi-be:
5 | build:
6 | context: .
7 | dockerfile: .docker/Dockerfile
8 | image: fi0ksi/ksi-be
9 | ports:
10 | - "3030:3030"
11 | volumes:
12 | - ./.docker/data/db:/opt/database
13 | - ./.docker/data/data:/opt/data
14 | - ./.docker/data/seminar.git:/opt/seminar.git # Not needed if SEMINAR_GIT_URL is set to value other than ::local::
15 | environment:
16 | - SEMINAR_GIT_URL=${SEMINAR_GIT_URL:-::local::} # If set to value other than ::local::, clones seminar repository from this origin instead of creating a blank one
17 | container_name: ksi-be
18 | devices:
19 | - /dev/fuse:/dev/fuse
20 | cap_add:
21 | - SYS_ADMIN
22 | privileged: true
23 | security_opt:
24 | - apparmor:unconfined
25 | entrypoint: ["/bin/bash", "./.docker/entrypoint.sh"]
26 | ksi-fe-dev:
27 | container_name: ksi-fe-local
28 | image: fi0ksi/ksi-fe-local
29 | ports:
30 | - "4201:80"
31 | ksi-fe-old-dev:
32 | container_name: ksi-fe-old-dev
33 | image: fi0ksi/ksi-fe-old-local
34 | ports:
35 | - "8080:80"
36 |
--------------------------------------------------------------------------------
/encryption.py:
--------------------------------------------------------------------------------
1 | from ssage import SSAGE
2 | from ssage.backend import SSAGEBackendAge
3 |
4 | from config import ENCRYPTION_KEY
5 |
6 |
7 | def get_encryptor() -> SSAGE:
8 | """
9 | Get an encryptor object
10 | :return: SSAGE object
11 | """
12 | return SSAGE(ENCRYPTION_KEY, authenticate=False, strip=False, backend=SSAGEBackendAge)
13 |
14 |
15 | def encrypt(data: str) -> str:
16 | """
17 | Encrypt data using AGE encryption
18 | :param data: data to encrypt
19 | :return: ASCII armored encrypted data
20 | """
21 | return get_encryptor().encrypt(data)
22 |
23 |
24 | def decrypt(data: str) -> str:
25 | """
26 | Decrypt data using AGE encryption
27 | :param data: ASCII armored encrypted data
28 | :return: decrypted data
29 | """
30 | return get_encryptor().decrypt(data)
31 |
--------------------------------------------------------------------------------
/endpoint/__init__.py:
--------------------------------------------------------------------------------
1 | from endpoint.article import Article, Articles
2 | from endpoint.achievement import Achievement, Achievements, AchievementSuccessfulParticipant
3 | from endpoint.post import Post, Posts
4 | from endpoint.task import Task, Tasks, TaskDetails
5 | from endpoint.module import Module, ModuleSubmit, ModuleSubmittedFile
6 | from endpoint.thread import Thread, Threads, ThreadDetails
7 | from endpoint.user import User, Users, ChangePassword, ForgottenPassword, DiscordInviteLink, DiscordBotValidateUser
8 | from endpoint.registration import Registration
9 | from endpoint.profile import Profile, PictureUploader, OrgProfile, BasicProfile
10 | from endpoint.image import Image
11 | from endpoint.content import Content, TaskContent
12 | from endpoint.oauth2 import Authorize, Logout
13 | from endpoint.runcode import RunCode
14 | from endpoint.feedback_email import FeedbackEmail
15 | from endpoint.feedback_task import FeedbackTask, FeedbacksTask
16 | from endpoint.wave import Wave, Waves
17 | from endpoint.year import Year, Years
18 | from endpoint.robots import Robots
19 | from endpoint.csp import CSP
20 | from endpoint.unsubscribe import Unsubscribe
21 | from endpoint.diploma import Diploma, DiplomaDownload
22 |
23 | from . import admin
24 |
--------------------------------------------------------------------------------
/endpoint/achievement.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import json
3 | from sqlalchemy import or_
4 | from sqlalchemy.exc import SQLAlchemyError
5 |
6 | import util
7 | from db import session
8 | import model
9 | from util.config import successful_participant_trophy_id
10 |
11 |
12 | class Achievement(object):
13 |
14 | def on_get(self, req, resp, id):
15 | try:
16 | achievement = session.query(model.Achievement).get(id)
17 | except SQLAlchemyError:
18 | session.rollback()
19 | raise
20 |
21 | if achievement is None:
22 | req.context['result'] = {
23 | 'errors': [{
24 | 'status': '404',
25 | 'title': 'Not found',
26 | 'detail': 'Trofej s tímto ID neexistuje.'
27 | }]
28 | }
29 | resp.status = falcon.HTTP_404
30 | return
31 |
32 | req.context['result'] = {
33 | 'achievement': util.achievement.to_json(achievement)
34 | }
35 |
36 | def on_delete(self, req, resp, id):
37 | user = req.context['user']
38 | try:
39 | achievement = session.query(model.Achievement).get(id)
40 | except SQLAlchemyError:
41 | session.rollback()
42 | raise
43 |
44 | if (not user.is_logged_in()) or (not user.is_admin()):
45 | req.context['result'] = {
46 | 'errors': [{
47 | 'status': '401',
48 | 'title': 'Unauthorized',
49 | 'detail': ('Smazání trofeje může provést pouze '
50 | 'administrátor.')
51 | }]
52 | }
53 | resp.status = falcon.HTTP_400
54 | return
55 |
56 | if not achievement:
57 | req.context['result'] = {
58 | 'errors': [{
59 | 'status': '404',
60 | 'title': 'Not Found',
61 | 'detail': 'Trofej s tímto ID neexsituje.'
62 | }]
63 | }
64 | resp.status = falcon.HTTP_404
65 | return
66 |
67 | try:
68 | session.delete(achievement)
69 | session.commit()
70 | except SQLAlchemyError:
71 | session.rollback()
72 | raise
73 | finally:
74 | session.close()
75 |
76 | req.context['result'] = {}
77 |
78 | # UPDATE trofeje
79 | def on_put(self, req, resp, id):
80 | user = req.context['user']
81 |
82 | # Upravovat trofeje mohou jen orgove
83 | if (not user.is_logged_in()) or (not user.is_org()):
84 | req.context['result'] = {
85 | 'errors': [{
86 | 'status': '401',
87 | 'title': 'Unauthorized',
88 | 'detail': 'Úpravu trofeje může provést pouze organizátor.'
89 | }]
90 | }
91 | resp.status = falcon.HTTP_400
92 | return
93 |
94 | data = json.loads(req.stream.read().decode('utf-8'))['achievement']
95 |
96 | try:
97 | achievement = session.query(model.Achievement).get(id)
98 | except SQLAlchemyError:
99 | session.rollback()
100 | raise
101 |
102 | if achievement is None:
103 | req.context['result'] = {
104 | 'errors': [{
105 | 'status': '404',
106 | 'title': 'Not Found',
107 | 'detail': 'Trofej s tímto ID neexistuje.'
108 | }]
109 | }
110 | resp.status = falcon.HTTP_404
111 | return
112 |
113 | achievement.title = data['title']
114 | achievement.picture = data['picture']
115 | achievement.description = data['description']
116 | if not data['persistent']:
117 | achievement.year = req.context['year']
118 | else:
119 | achievement.year = None
120 |
121 | try:
122 | session.commit()
123 | except SQLAlchemyError:
124 | session.rollback()
125 | raise
126 | finally:
127 | session.close()
128 |
129 | self.on_get(req, resp, id)
130 |
131 |
132 | class AchievementSuccessfulParticipant:
133 | def on_get(self, req, resp):
134 | trophy_id = successful_participant_trophy_id()
135 |
136 | if trophy_id is None:
137 | req.context['result'] = {
138 | 'errors': [{
139 | 'status': '404',
140 | 'title': 'Not Found',
141 | 'detail': 'Successful trophy was not set'
142 | }]
143 | }
144 |
145 | return Achievement().on_get(req, resp, trophy_id)
146 |
147 |
148 | class Achievements(object):
149 |
150 | def on_get(self, req, resp):
151 | try:
152 | achievements = session.query(model.Achievement).\
153 | filter(or_(model.Achievement.year == None,
154 | model.Achievement.year == req.context['year'])).\
155 | all()
156 | except SQLAlchemyError:
157 | session.rollback()
158 | raise
159 |
160 | req.context['result'] = {
161 | 'achievements': [util.achievement.to_json(achievement)
162 | for achievement in achievements]
163 | }
164 |
165 | # Vytvoreni nove trofeje
166 | def on_post(self, req, resp):
167 | user = req.context['user']
168 |
169 | # Vytvoret novou trofej mohou jen orgove
170 | if (not user.is_logged_in()) or (not user.is_org()):
171 | req.context['result'] = {
172 | 'errors': [{
173 | 'status': '401',
174 | 'title': 'Unauthorized',
175 | 'detail': 'Přidání trofeje může provést pouze organizátor.'
176 | }]
177 | }
178 | resp.status = falcon.HTTP_400
179 | return
180 |
181 | data = json.loads(req.stream.read().decode('utf-8'))['achievement']
182 |
183 | achievement = model.Achievement(
184 | title=data['title'],
185 | picture=data['picture'],
186 | description=data['description'],
187 | )
188 | if not data['persistent']:
189 | achievement.year = req.context['year']
190 | else:
191 | achievement.year = None
192 |
193 | try:
194 | session.add(achievement)
195 | session.commit()
196 | except SQLAlchemyError:
197 | session.rollback()
198 | raise
199 |
200 | req.context['result'] = {
201 | 'achievement': util.achievement.to_json(achievement)
202 | }
203 |
204 | session.close()
205 |
--------------------------------------------------------------------------------
/endpoint/admin/__init__.py:
--------------------------------------------------------------------------------
1 | from endpoint.admin.evaluations import Evaluation
2 | from endpoint.admin.corrections import Corrections
3 | from endpoint.admin.corrections import Correction
4 | from endpoint.admin.correctionsInfo import CorrectionsInfo
5 | from endpoint.admin.correctionsInfo import CorrectionInfo
6 | from endpoint.admin.correctionsPublish import CorrectionsPublish
7 | from endpoint.admin.correctionsEmail import CorrectionsEmail
8 | from endpoint.admin.submFilesEval import SubmFilesEval
9 | from endpoint.admin.submFilesTask import SubmFilesTask
10 | from endpoint.admin.email import Email
11 | from endpoint.admin.task import Task
12 | from endpoint.admin.task import Tasks
13 | from endpoint.admin.taskDeploy import TaskDeploy
14 | from endpoint.admin.taskMerge import TaskMerge
15 | from endpoint.admin.waveDiff import WaveDiff
16 | from endpoint.admin.achievementGrant import AchievementGrant
17 | from endpoint.admin.userExport import UserExport
18 | from endpoint.admin.evalCode import EvalCode
19 | from endpoint.admin.execs import Execs
20 | from endpoint.admin.execs import Exec
21 | from endpoint.admin.monitoringDashboard import MonitoringDashboard
22 | from endpoint.admin.diploma import DiplomaGrant
23 | from endpoint.admin.instanceConfig import InstanceConfig
24 |
--------------------------------------------------------------------------------
/endpoint/admin/achievementGrant.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import json
3 | from sqlalchemy.exc import SQLAlchemyError
4 |
5 | from db import session
6 | import model
7 | import util
8 |
9 |
10 | class AchievementGrant(object):
11 |
12 | def on_post(self, req, resp):
13 | """
14 | Prideleni achievementu
15 |
16 | Format dat:
17 | {
18 | "users": [ id ],
19 | "task": (null|id),
20 | "achievement": id
21 | }
22 |
23 | """
24 | try:
25 | user = req.context['user']
26 | data = json.loads(req.stream.read().decode('utf-8'))
27 |
28 | if (not user.is_logged_in()) or (not user.is_org()):
29 | resp.status = falcon.HTTP_400
30 | return
31 |
32 | errors = []
33 | req.context['result'] = {
34 | 'errors': [{
35 | 'status': '401',
36 | 'title': 'Unauthorized',
37 | 'detail': 'Přístup odepřen.'
38 | }]
39 | }
40 |
41 | for u in data['users']:
42 | if not data['task']:
43 | data['task'] = None
44 | else:
45 | evl = session.query(model.Evaluation).\
46 | filter(model.Evaluation.user == u).\
47 | join(model.Module,
48 | model.Module.id == model.Evaluation.module).\
49 | filter(model.Module.task == data['task']).\
50 | first()
51 |
52 | if not evl:
53 | errors.append({
54 | 'title': ("Uživatel " + str(u) +
55 | " neodevzdal vybranou úlohu\n")
56 | })
57 | continue
58 |
59 | if session.query(model.UserAchievement).\
60 | get((u, data['achievement'])):
61 | errors.append({
62 | 'title': ("Uživateli " + str(u) +
63 | " je již trofej přidělena\n")
64 | })
65 | else:
66 | ua = model.UserAchievement(
67 | user_id=u,
68 | achievement_id=data['achievement'],
69 | task_id=data['task']
70 | )
71 | session.add(ua)
72 |
73 | session.commit()
74 | if len(errors) > 0:
75 | req.context['result'] = {'errors': errors}
76 | else:
77 | req.context['result'] = {}
78 |
79 | except SQLAlchemyError:
80 | session.rollback()
81 | raise
82 | finally:
83 | session.close()
84 |
--------------------------------------------------------------------------------
/endpoint/admin/correctionsInfo.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | from sqlalchemy import func
3 | from sqlalchemy.exc import SQLAlchemyError
4 |
5 | from db import session
6 | import model
7 | import util
8 |
9 |
10 | class CorrectionInfo(object):
11 |
12 | def on_get(self, req, resp, id):
13 | try:
14 | user = req.context['user']
15 | year = req.context['year']
16 |
17 | if (not user.is_logged_in()) or (not user.is_org()):
18 | resp.status = falcon.HTTP_400
19 | return
20 |
21 | task = session.query(model.Task).get(id)
22 | if not task:
23 | resp.status = falcon.HTTP_404
24 | return
25 |
26 | req.context['result'] = {
27 | 'correctionsInfo': util.correctionInfo.task_to_json(task)
28 | }
29 | except SQLAlchemyError:
30 | session.rollback()
31 | raise
32 | finally:
33 | session.close()
34 |
35 |
36 | class CorrectionsInfo(object):
37 |
38 | def on_get(self, req, resp):
39 | """
40 | Specifikace GET pozadavku:
41 | Prazdny pozadavek vracici ulohy, vlny a uzivatele pro vyplneni filtru
42 | opravovatka.
43 |
44 | """
45 |
46 | try:
47 | user = req.context['user']
48 | year = req.context['year']
49 |
50 | if (not user.is_logged_in()) or (not user.is_org()):
51 | req.context['result'] = {
52 | 'errors': [{
53 | 'status': '401',
54 | 'title': 'Unauthorized',
55 | 'detail': ('Přístup k opravovátku mají pouze '
56 | 'organizátoři.')
57 | }]
58 | }
59 | resp.status = falcon.HTTP_400
60 | return
61 |
62 | tasks = session.query(model.Task).\
63 | join(model.Wave, model.Wave.id == model.Task.wave).\
64 | filter(model.Wave.year == year).all()
65 |
66 | waves = session.query(model.Wave).\
67 | filter(model.Wave.year == year).\
68 | join(model.Task, model.Task.wave == model.Wave.id).all()
69 |
70 | users = session.query(model.User)
71 | users = set(util.user.active_in_year(users, year).all())
72 | users |= set(session.query(model.User).
73 | join(model.Task,
74 | model.Task.author == model.User.id).all())
75 |
76 | solvers = session.query(model.Task.id, model.Evaluation.user).\
77 | join(model.Wave, model.Wave.id == model.Task.wave).\
78 | filter(model.Wave.year == year).\
79 | join(model.Module, model.Module.task == model.Task.id).\
80 | join(model.Evaluation,
81 | model.Module.id == model.Evaluation.module).\
82 | group_by(model.Task.id, model.Evaluation.user).\
83 | all()
84 |
85 | evaluating = session.query(model.Task.id).\
86 | join(model.Module, model.Module.task == model.Task.id).\
87 | filter(model.Evaluation.evaluator != None).\
88 | join(model.Evaluation,
89 | model.Evaluation.module == model.Module.id).\
90 | all()
91 | evaluating = [r for (r,) in evaluating]
92 |
93 | tasks_corrected = util.correction.tasks_corrected()
94 |
95 | req.context['result'] = {
96 | 'correctionsInfos': [
97 | util.correctionInfo.task_to_json(
98 | task,
99 | list(map(
100 | lambda t: t[1],
101 | filter(lambda t: t[0] == task.id, solvers)
102 | )),
103 | task.id in evaluating,
104 | tasks_corrected
105 | ) for task in tasks
106 | ],
107 | 'waves': [
108 | util.wave.to_json(wave) for wave in waves
109 | ],
110 | 'users': [
111 | util.correctionInfo.user_to_json(user) for user in users
112 | ]
113 | }
114 |
115 | except SQLAlchemyError:
116 | session.rollback()
117 | raise
118 | finally:
119 | session.close()
120 |
--------------------------------------------------------------------------------
/endpoint/admin/correctionsPublish.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | from sqlalchemy import func
3 | from sqlalchemy.exc import SQLAlchemyError
4 |
5 | from db import session
6 | import model
7 | import util
8 |
9 |
10 | class CorrectionsPublish(object):
11 |
12 | def on_get(self, req, resp, id):
13 | """
14 | Specifikace GET pozadavku:
15 | ?public=(1|0)
16 | tento argument je nepovinny, pokud neni vyplnen, dojde ke
17 | zverejneni
18 |
19 | """
20 | try:
21 | user = req.context['user']
22 | public = req.get_param_as_bool('public')
23 |
24 | if (not user.is_logged_in()) or (not user.is_org()):
25 | resp.status = falcon.HTTP_400
26 | return
27 |
28 | if public is None:
29 | public = True
30 |
31 | task = session.query(model.Task).get(id)
32 | if task is None:
33 | resp.status = falcon.HTTP_404
34 | return
35 | task.evaluation_public = public
36 | session.commit()
37 | req.context['result'] = {}
38 | except SQLAlchemyError:
39 | session.rollback()
40 | raise
41 | finally:
42 | session.close()
43 |
--------------------------------------------------------------------------------
/endpoint/admin/diploma.py:
--------------------------------------------------------------------------------
1 | import tempfile
2 |
3 | import falcon
4 | import magic
5 | import multipart
6 | from pathlib import Path
7 |
8 | from sqlalchemy.exc import SQLAlchemyError
9 |
10 | import model
11 | from db import session
12 | from shutil import move
13 |
14 | ALLOWED_MIME_TYPES = ('application/pdf',)
15 | UPLOAD_DIR = Path().joinpath('data').joinpath('diplomas')
16 |
17 |
18 | def get_diploma_path(year_id: int, user_id: int) -> Path:
19 | return UPLOAD_DIR.joinpath(f"year_{year_id}").joinpath(f"user_{user_id}.pdf")
20 |
21 |
22 | class DiplomaGrant:
23 | def on_post(self, req, resp, id):
24 | try:
25 | userinfo = req.context['user']
26 |
27 | if not userinfo.is_logged_in() or not userinfo.is_admin():
28 | resp.status = falcon.HTTP_401
29 | req.context['result'] = {
30 | 'result': 'error',
31 | 'error': 'Nahravat diplomy muze pouze admin.'
32 | }
33 | return
34 |
35 | if req.context['year_obj'].sealed:
36 | resp.status = falcon.HTTP_403
37 | req.context['result'] = {
38 | 'errors': [{
39 | 'status': '403',
40 | 'title': 'Forbidden',
41 | 'detail': 'Ročník zapečetěn.'
42 | }]
43 | }
44 | return
45 |
46 | content_type, options = multipart.parse_options_header(
47 | req.content_type
48 | )
49 | boundary = options.get('boundary', '')
50 |
51 | if not boundary:
52 | raise multipart.MultipartError("No boundary for "
53 | "multipart/form-data.")
54 |
55 | for part in multipart.MultipartParser(req.stream, boundary,
56 | req.content_length):
57 | file = part # take only the first file
58 | break
59 | else:
60 | resp.status = falcon.HTTP_400
61 | req.context['result'] = {
62 | 'errors': [{
63 | 'status': '400',
64 | 'title': 'Bad Request',
65 | 'detail': 'No file found in the data'
66 | }]
67 | }
68 | return
69 |
70 | user_id = id
71 | year_id = req.context['year_obj'].id
72 | tmpfile = tempfile.NamedTemporaryFile(delete=False)
73 |
74 | file.save_as(tmpfile.name)
75 |
76 | mime = magic.Magic(mime=True).from_file(tmpfile.name)
77 |
78 | if mime not in ALLOWED_MIME_TYPES:
79 | resp.status = falcon.HTTP_400
80 | req.context['result'] = {
81 | 'errors': [{
82 | 'status': '403',
83 | 'title': 'Forbidden',
84 | 'detail': f'Allowed file types are {ALLOWED_MIME_TYPES}'
85 | }]
86 | }
87 | return
88 |
89 | target_file = get_diploma_path(year_id, user_id)
90 |
91 | try:
92 | target_file.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
93 | except OSError:
94 | print('Unable to create directory for profile pictures')
95 | resp.status = falcon.HTTP_500
96 | return
97 |
98 | try:
99 | move(tmpfile.name, target_file)
100 | except OSError:
101 | print('Unable to remove temporary file %s' % tmpfile.name)
102 |
103 | diploma = model.Diploma(
104 | user_id=user_id,
105 | year_id=year_id
106 | )
107 | session.add(diploma)
108 |
109 | session.commit()
110 |
111 | req.context['result'] = {}
112 | except SQLAlchemyError:
113 | session.rollback()
114 | raise
115 | finally:
116 | session.close()
117 |
--------------------------------------------------------------------------------
/endpoint/admin/email.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import json
3 | from sqlalchemy.exc import SQLAlchemyError
4 |
5 | from db import session
6 | import model
7 | import util
8 | from util import logger
9 |
10 |
11 | class Email(object):
12 |
13 | def on_post(self, req, resp):
14 | """
15 | Specifikace POST pozadavku:
16 | {
17 | "Subject": String,
18 | "Body": String,
19 | "Reply-To": String,
20 | "To": [year_id_1, year_id_2, ...] (resitelum v danych rocnicich),
21 | "Bcc": [String],
22 | "Gender": (both|male|female) - pokud neni vyplneno, je automaticky
23 | povazovano za "both",
24 | "KarlikSign": (true|false),
25 | "Easteregg": (true|false),
26 | "Successful": (true|false),
27 | "Category": ("hs", "other", "both"),
28 | "Type": ("ksi", "events"),
29 | }
30 |
31 | Backend edpovida:
32 | {
33 | count: Integer
34 | }
35 |
36 | """
37 |
38 | try:
39 | user = req.context['user']
40 |
41 | if (not user.is_logged_in()) or (not user.is_org()):
42 | resp.status = falcon.HTTP_400
43 | return
44 |
45 | data = json.loads(req.stream.read().decode('utf-8'))['e-mail']
46 |
47 | logger.audit_log(
48 | scope="MAIL",
49 | user_id=user.id,
50 | message="Sent an email",
51 | message_meta=dict(data)
52 | )
53 |
54 | # Filtrovani uzivatelu
55 | if ('Successful' in data) and (data['Successful']):
56 | tos = {}
57 | for year in data['To']:
58 | year_obj = session.query(model.Year).get(year)
59 | tos.update({
60 | user.id: user
61 | for (user, _) in util.user.successful_participants(
62 | year_obj
63 | )
64 | })
65 |
66 | else:
67 | tos = {
68 | user.id: user
69 | for user, year in util.user.active_years_all()
70 | if user.role == 'participant' and year.id in data['To']
71 | }
72 |
73 | if ('Gender' in data) and (data['Gender'] != 'both'):
74 | tos = {
75 | user.id: user
76 | for user in tos.values()
77 | if user.sex == data['Gender']
78 | }
79 |
80 | if 'Category' in data and data['Category'] != 'both':
81 | min_year = util.year.year_end(session.query(model.Year).
82 | get(min(data['To'])))
83 | max_year = util.year.year_end(session.query(model.Year).
84 | get(max(data['To'])))
85 |
86 | finish = {
87 | id: year
88 | for (id, year) in session.query(
89 | model.Profile.user_id,
90 | model.Profile.school_finish
91 | ).all()
92 | }
93 |
94 | if data['Category'] == 'hs':
95 | tos = {
96 | user.id: user
97 | for user in tos.values()
98 | if finish[user.id] >= min_year
99 | }
100 | elif data['Category'] == 'other':
101 | tos = {
102 | user.id: user
103 | for user in tos.values()
104 | if finish[user.id] < max_year
105 | }
106 |
107 | params = {}
108 |
109 | if 'Reply-To' in data and data['Reply-To']:
110 | params['Reply-To'] = data['Reply-To']
111 |
112 | body = data['Body']
113 | if ('KarlikSign' in data) and (data['KarlikSign']):
114 | body = body + util.config.mail_sign()
115 | if ('Easteregg' in data) and (data['Easteregg']):
116 | body = body + util.mail.easteregg()
117 |
118 | # Select notifications to build unsubscribes
119 | notifies = {
120 | n.user: n for n in session.query(model.UserNotify).all()
121 | }
122 |
123 | TYPE_MAPPING = {
124 | 'ksi': util.mail.EMailType.KSI,
125 | 'events': util.mail.EMailType.EVENTS,
126 | }
127 |
128 | message_type = (
129 | TYPE_MAPPING[data['Type']]
130 | if 'Type' in data and data['Type'] in TYPE_MAPPING
131 | else util.mail.EMailType.KSI
132 | )
133 |
134 | # Filter unsubscribed
135 | tos = {
136 | user_id: user
137 | for user_id, user in tos.items()
138 | if user_id in notifies and ( # some older users do not have to be inside notifies, filter them out
139 | (message_type == util.mail.EMailType.KSI and notifies[user_id].notify_ksi) or
140 | (message_type == util.mail.EMailType.EVENTS and notifies[user_id].notify_events)
141 | )
142 | }
143 |
144 | recipients = [
145 | util.mail.EMailRecipient(
146 | user.email,
147 | util.mail.Unsubscribe(
148 | message_type,
149 | notifies[user.id] if user.id in notifies else None,
150 | user.id,
151 | commit=False, # we will commit new entries only once
152 | backend_url=util.config.backend_url(),
153 | ksi_web=util.config.ksi_web(),
154 | )
155 | ) for user in tos.values()
156 | ]
157 |
158 | try:
159 | util.mail.send_multiple(
160 | recipients,
161 | data['Subject'],
162 | body,
163 | params,
164 | data['Bcc'],
165 | )
166 | req.context['result'] = {'count': len(tos)}
167 | session.commit()
168 | except Exception as e:
169 | req.context['result'] = {'error': str(e)}
170 | resp.status = falcon.HTTP_500
171 |
172 | except SQLAlchemyError:
173 | session.rollback()
174 | raise
175 | finally:
176 | session.close()
177 |
--------------------------------------------------------------------------------
/endpoint/admin/evalCode.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import os
3 | from sqlalchemy.exc import SQLAlchemyError
4 |
5 | from db import session
6 | import model
7 | import util
8 |
9 |
10 | class EvalCode(object):
11 |
12 | def _file_or_error(self, fn):
13 | if not os.path.isfile(fn):
14 | return "Soubor %s neexistuje." % (fn)
15 |
16 | with open(fn, "r") as f:
17 | return f.read()
18 |
19 | def on_get(self, req, resp, id):
20 | try:
21 | user = req.context['user']
22 |
23 | if (not user.is_logged_in()) or (not user.is_org()):
24 | resp.status = falcon.HTTP_400
25 | return
26 |
27 | code = session.query(model.SubmittedCode).\
28 | filter(model.SubmittedCode.evaluation == id).first()
29 |
30 | if not code:
31 | req.context['result'] = {
32 | 'errors': [{'id': 5, 'title': "Code not found in db"}]
33 | }
34 | return
35 |
36 | evaluation = session.query(model.Evaluation).get(code.evaluation)
37 | if not evaluation:
38 | req.context['result'] = {
39 | 'errors': [
40 | {'id': 5, 'title': "Evaluation not found in db"}
41 | ]
42 | }
43 | return
44 |
45 | eval_dir = os.path.join(
46 | 'data',
47 | 'exec',
48 | 'module_' + str(evaluation.module),
49 | 'user_' + str(evaluation.user))
50 |
51 | CODE_PATH = os.path.join(eval_dir, 'box', 'run')
52 | STDOUT_PATH = os.path.join(eval_dir, 'stdout')
53 | STDERR_PATH = os.path.join(eval_dir, 'stderr')
54 | MERGE_STDOUT = os.path.join(eval_dir, 'merge.stdout')
55 | CHECK_STDOUT = os.path.join(eval_dir, 'check.stdout')
56 | SOURCE_PATH = os.path.join(eval_dir, util.programming.SOURCE_FILE)
57 |
58 | lines = []
59 | if os.path.isfile(SOURCE_PATH):
60 | with open(SOURCE_PATH, 'r') as s:
61 | lines = s.read().split('\n')
62 |
63 | if len(lines) >= 2 and (lines[0] != "evaluation" or
64 | lines[1] != str(id)):
65 | req.context['result'] = {
66 | 'evalCode': {
67 | 'id': evaluation.id,
68 | 'code': code.code,
69 | 'merged': ('Další záznamy o vyhodnocení už nejsou k '
70 | 'dispozici, byly nahrazeny novým opravením '
71 | 'nebo spuštěním.'),
72 | }
73 | }
74 | return
75 |
76 | req.context['result'] = {
77 | 'evalCode': {
78 | 'id': evaluation.id,
79 | 'code': code.code,
80 | 'merged': self._file_or_error(CODE_PATH),
81 | 'stdout': self._file_or_error(STDOUT_PATH),
82 | 'stderr': self._file_or_error(STDERR_PATH),
83 | 'merge_stdout': self._file_or_error(MERGE_STDOUT),
84 | 'check_stdout': self._file_or_error(CHECK_STDOUT),
85 | }
86 | }
87 | except SQLAlchemyError:
88 | session.rollback()
89 | raise
90 | finally:
91 | session.close()
92 |
--------------------------------------------------------------------------------
/endpoint/admin/evaluations.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | from sqlalchemy.exc import SQLAlchemyError
3 |
4 | from db import session
5 | import model
6 | import util
7 |
8 |
9 | class Evaluation(object):
10 |
11 | def on_get(self, req, resp, id):
12 | """
13 | GET pozadavek na konkretni correction se spousti prevazne jako odpoved
14 | na POST.
15 | id je umele id, konstrukce viz util/correction.py
16 | Parametry: moduleX_version=Y (X a Y jsou cisla)
17 |
18 | """
19 |
20 | try:
21 | user = req.context['user']
22 |
23 | if (not user.is_logged_in()) or (not user.is_org()):
24 | resp.status = falcon.HTTP_400
25 | return
26 |
27 | evaluation = session.query(model.Evaluation).get(id)
28 | if evaluation is None:
29 | resp.status = falcon.HTTP_404
30 | return
31 |
32 | module = session.query(model.Module).get(evaluation.module)
33 |
34 | req.context['result'] = {
35 | 'evaluation': util.correction.corr_eval_to_json(module,
36 | evaluation)
37 | }
38 | except SQLAlchemyError:
39 | session.rollback()
40 | raise
41 | finally:
42 | session.close()
43 |
--------------------------------------------------------------------------------
/endpoint/admin/execs.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | from sqlalchemy import desc
3 | from sqlalchemy.exc import SQLAlchemyError
4 |
5 | from db import session
6 | from model import CodeExecution as CE
7 | import util
8 |
9 |
10 | class Exec(object):
11 | """ This endpoint returns single code execution. """
12 |
13 | def on_get(self, req, resp, id):
14 | try:
15 | user = req.context['user']
16 |
17 | if (not user.is_logged_in()) or (not user.is_org()):
18 | resp.status = falcon.HTTP_400
19 | return
20 |
21 | execution = sessiton.query(CE).get(id)
22 |
23 | if not execution:
24 | resp.status = falcon.HTTP_404
25 | return
26 |
27 | req.context['result'] = util.programming.exec_to_json(execution)
28 |
29 | except SQLAlchemyError:
30 | session.rollback()
31 | raise
32 |
33 | finally:
34 | session.close()
35 |
36 |
37 | class Execs(object):
38 | """ This endpoint returns code executions. """
39 |
40 | def on_get(self, req, resp):
41 | """
42 | You can send request with any of these GET parameters:
43 | ?user=user_id
44 | ?module=module_id
45 | ?limit=uint, (default=20)
46 | ?page=uint,
47 | ?from=datetime,
48 | ?to=datetime,
49 | ?result=(ok,error),
50 |
51 | Most recent evaluatons are returned first.
52 | """
53 |
54 | try:
55 | user = req.context['user']
56 |
57 | if (not user.is_logged_in() or not user.is_org()):
58 | resp.status = falcon.HTTP_400
59 | return
60 |
61 | execs = session.query(CE)
62 |
63 | ruser = req.get_param_as_int('user')
64 | if ruser is not None:
65 | execs = execs.filter(CE.user == ruser)
66 |
67 | rmodule = req.get_param_as_int('module')
68 | if rmodule is not None:
69 | execs = execs.filter(CE.module == rmodule)
70 |
71 | rfrom = req.get_param_as_datetime('from', '%Y-%m-%d %H:%M:%S')
72 | if rfrom is not None:
73 | execs = execs.filter(CE.time >= rfrom)
74 |
75 | rto = req.get_param_as_datetime('to', '%Y-%m-%d %H:%M:%S')
76 | if rto is not None:
77 | execs = execs.filter(CE.time <= rto)
78 |
79 | rresult = req.get_param('result')
80 | if rresult is not None:
81 | execs = execs.filter(CE.result == rresult)
82 |
83 | limit = req.get_param_as_int('limit')
84 | if limit is None or limit < 1:
85 | limit = 20
86 | if limit > 100:
87 | limit = 100
88 |
89 | page = req.get_param_as_int('page')
90 | if page is None or page < 0:
91 | page = 0
92 |
93 | count = execs.count()
94 |
95 | execs = execs.order_by(desc(CE.id)).\
96 | slice(limit*page, limit*(page+1))
97 | execs = execs.all()
98 |
99 | req.context['result'] = {
100 | 'execs': [util.programming.exec_to_json(ex) for ex in execs],
101 | 'meta': {
102 | 'total': count,
103 | 'page': page,
104 | },
105 | }
106 |
107 | except SQLAlchemyError:
108 | session.rollback()
109 | raise
110 |
111 | finally:
112 | session.close()
113 |
--------------------------------------------------------------------------------
/endpoint/admin/instanceConfig.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import falcon
4 |
5 | from sqlalchemy.exc import SQLAlchemyError
6 |
7 | import util.config
8 | from db import session
9 | from util.logger import audit_log
10 |
11 |
12 | class InstanceConfig:
13 | def on_post(self, req, resp):
14 | try:
15 | userinfo = req.context['user']
16 |
17 | if not userinfo.is_logged_in() or not userinfo.is_admin():
18 | resp.status = falcon.HTTP_401
19 | req.context['result'] = {
20 | 'result': 'error',
21 | 'error': 'Only admin can change the instance configuration.'
22 | }
23 | return
24 |
25 | data = json.loads(req.stream.read().decode('utf-8'))
26 | key = data['key']
27 | value = data['value']
28 |
29 | if key is None or value is None:
30 | resp.status = falcon.HTTP_400
31 | req.context['result'] = {
32 | 'result': 'error',
33 | 'error': 'Missing key or value.'
34 | }
35 | return
36 |
37 | current_value = util.config.ConfigCache.instance().get(key, none_if_secret=True)
38 | audit_log(
39 | scope="CONFIG",
40 | user_id=userinfo.id,
41 | message=f"Changed instance config {key}",
42 | message_meta={"key": key, "previous": current_value}
43 | )
44 | util.config.set_config(key, value)
45 | req.context['result'] = {}
46 | except SQLAlchemyError:
47 | session.rollback()
48 | raise
49 | finally:
50 | session.close()
51 |
52 | def on_get(self, req, resp):
53 | try:
54 | userinfo = req.context['user']
55 |
56 | if not userinfo.is_logged_in() or not userinfo.is_admin():
57 | resp.status = falcon.HTTP_401
58 | req.context['result'] = {
59 | 'result': 'error',
60 | 'error': 'Only admin can see the instance configuration.'
61 | }
62 | return
63 |
64 | req.context['result'] = {'config': list(util.config.get_all(include_secret=False).values())}
65 | except SQLAlchemyError:
66 | session.rollback()
67 | raise
68 | finally:
69 | session.close()
70 |
--------------------------------------------------------------------------------
/endpoint/admin/monitoringDashboard.py:
--------------------------------------------------------------------------------
1 | import falcon
2 |
3 | from util import config
4 |
5 | class MonitoringDashboard(object):
6 |
7 | def on_get(self, req, resp):
8 | user = req.context['user']
9 |
10 | if (not user.is_logged_in()) or (not user.is_org()):
11 | req.context['result'] = 'Nedostatecna opravneni'
12 | resp.status = falcon.HTTP_400
13 | return
14 |
15 | resp.media = {'url': config.monitoring_dashboard_url()}
16 | resp.status = falcon.HTTP_200
17 |
18 |
--------------------------------------------------------------------------------
/endpoint/admin/submFilesEval.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | from sqlalchemy.exc import SQLAlchemyError
3 | from zipfile import ZipFile
4 | from io import BytesIO
5 | import os
6 |
7 | from db import session
8 | import model
9 | import util
10 |
11 |
12 | class SubmFilesEval(object):
13 |
14 | def on_get(self, req, resp, eval_id):
15 | """ Vraci vsechny soubory daneho hodnoceni. """
16 |
17 | try:
18 | user = req.context['user']
19 |
20 | if (not user.is_logged_in()) or (not user.is_org()):
21 | resp.status = falcon.HTTP_400
22 | return
23 |
24 | inMemoryOutputFile = BytesIO()
25 | zipFile = ZipFile(inMemoryOutputFile, 'w')
26 |
27 | files = [
28 | r for (r, ) in
29 | session.query(model.SubmittedFile.path).
30 | filter(model.SubmittedFile.evaluation == eval_id).
31 | distinct()
32 | ]
33 |
34 | for fname in files:
35 | if os.path.isfile(fname):
36 | zipFile.write(fname, os.path.basename(fname))
37 |
38 | zipFile.close()
39 |
40 | resp.set_header('Content-Disposition',
41 | 'inline; filename="eval_' + str(eval_id) + '.zip"')
42 | resp.content_type = "application/zip"
43 | resp.body = inMemoryOutputFile.getvalue()
44 | resp.content_length = len(resp.body)
45 |
46 | inMemoryOutputFile.close()
47 | except SQLAlchemyError:
48 | session.rollback()
49 | raise
50 | finally:
51 | session.close()
52 |
--------------------------------------------------------------------------------
/endpoint/admin/submFilesTask.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | from sqlalchemy.exc import SQLAlchemyError
3 | from io import BytesIO
4 | from zipfile import ZipFile
5 | import os
6 |
7 | from db import session
8 | import model
9 | import util
10 |
11 | class SubmFilesTask(object):
12 |
13 | def on_get(self, req, resp, task_id):
14 | """ Vraci vsechny soubory vsech resitelu pro danou ulohu. """
15 |
16 | try:
17 | user = req.context['user']
18 |
19 | if (not user.is_logged_in()) or (not user.is_org()):
20 | resp.status = falcon.HTTP_400
21 | return
22 |
23 | inMemoryOutputFile = BytesIO()
24 | zipFile = ZipFile(inMemoryOutputFile, 'w')
25 |
26 | modules = session.query(model.Module).\
27 | filter(model.Module.task == task_id).all()
28 |
29 | for module in modules:
30 | users = session.query(model.User).\
31 | join(model.Evaluation,
32 | model.Evaluation.user == model.User.id).\
33 | filter(model.Evaluation.module == module.id).all()
34 |
35 | for user in users:
36 | files = [
37 | r for (r, ) in
38 | session.query(model.SubmittedFile.path).
39 | join(model.Evaluation,
40 | model.Evaluation.id ==
41 | model.SubmittedFile.evaluation).
42 | filter(model.Evaluation.user == user.id,
43 | model.Evaluation.module == module.id).
44 | distinct()
45 | ]
46 | userdir = (
47 | os.path.join(
48 | "module_" + str(module.id),
49 | util.submissions.strip_accents(user.first_name) +
50 | "_" +
51 | util.submissions.strip_accents(user.last_name)
52 | )
53 | ).replace(' ', '_')
54 |
55 | for fname in files:
56 | if os.path.isfile(fname):
57 | zipFile.write(
58 | fname,
59 | os.path.join(userdir, os.path.basename(fname))
60 | )
61 |
62 | zipFile.close()
63 |
64 | resp.set_header(
65 | 'Content-Disposition',
66 | 'inline; filename="task_' + str(task_id) + '.zip"'
67 | )
68 | resp.content_type = "application/zip"
69 | resp.body = inMemoryOutputFile.getvalue()
70 | resp.content_length = len(resp.body)
71 |
72 | inMemoryOutputFile.close()
73 | except SQLAlchemyError:
74 | session.rollback()
75 | raise
76 | finally:
77 | session.close()
78 |
--------------------------------------------------------------------------------
/endpoint/admin/taskDeploy.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import json
3 | import datetime
4 | import threading
5 | import os
6 | import re
7 | from sqlalchemy.exc import SQLAlchemyError
8 | from lockfile import LockFile
9 | from sqlalchemy.orm import scoped_session
10 |
11 | from db import session, _session
12 | import model
13 | import util
14 |
15 |
16 | class TaskDeploy(object):
17 |
18 | def on_post(self, req, resp, id):
19 | """
20 | Vraci JSON:
21 | {
22 | "result": "ok" | "error",
23 | "error": String
24 | }
25 |
26 | """
27 |
28 | try:
29 | user = req.context['user']
30 | year_id: int = req.context['year']
31 |
32 | # Kontrola opravneni
33 | if (not user.is_logged_in()) or (not user.is_org()):
34 | req.context['result'] = 'Nedostatecna opravneni'
35 | resp.status = falcon.HTTP_400
36 | return
37 |
38 | # Kontrola existence ulohy
39 | task = session.query(model.Task).get(id)
40 | if task is None:
41 | req.context['result'] = 'Neexistujici uloha'
42 | resp.status = falcon.HTTP_404
43 | return
44 |
45 | # Zverejnene ulohy mohou deployovat pouze admini a garant vlny
46 | wave = session.query(model.Wave).get(task.wave)
47 | if (datetime.datetime.utcnow() > wave.time_published and
48 | not user.is_admin() and user.id != wave.garant):
49 | req.context['result'] = ('Po zverejneni ulohy muze deploy '
50 | 'provest pouze administrator nebo '
51 | 'garant vlny.')
52 | resp.status = falcon.HTTP_404
53 | return
54 |
55 | # Kontrola existence gitovske vetve a adresare v databazi
56 | if (task.git_branch is None) or (task.git_path is None):
57 | req.context['result'] = ('Uloha nema zadanou gitovskou vetev '
58 | 'nebo adresar')
59 | resp.status = falcon.HTTP_400
60 | return
61 |
62 | # Kontrola zamku
63 | lock = util.lock.git_locked()
64 | if lock:
65 | req.context['result'] = ('GIT uzamcen zamkem ' + lock +
66 | '\nNekdo momentalne provadi akci s '
67 | 'gitem, opakujte prosim akci za 20 '
68 | 'sekund.')
69 | resp.status = falcon.HTTP_409
70 | return
71 |
72 | # Stav na deploying je potreba nastavit v tomto vlakne
73 | task.deploy_status = 'deploying'
74 | session.commit()
75 |
76 | try:
77 | deployLock = LockFile(util.admin.taskDeploy.LOCKFILE)
78 | deployLock.acquire(60) # Timeout zamku je 1 minuta
79 | deployThread = threading.Thread(
80 | target=util.admin.taskDeploy.deploy,
81 | args=(task.id, year_id, deployLock, scoped_session(_session)),
82 | kwargs={}
83 | )
84 | deployThread.start()
85 | finally:
86 | if deployLock.is_locked():
87 | deployLock.release()
88 |
89 | req.context['result'] = {}
90 | resp.status = falcon.HTTP_200
91 | except SQLAlchemyError:
92 | session.rollback()
93 | raise
94 | finally:
95 | session.close()
96 |
97 | def on_get(self, req, resp, id):
98 | """
99 | Vraci JSON:
100 | {
101 | "id": task_id,
102 | "log": String,
103 | "deploy_date": Datetime,
104 | "deploy_status": model.task.deploy_status
105 | }
106 |
107 | """
108 |
109 | try:
110 | user = req.context['user']
111 |
112 | # Kontrola opravneni
113 | if (not user.is_logged_in()) or (not user.is_org()):
114 | resp.status = falcon.HTTP_400
115 | return
116 |
117 | status = {}
118 |
119 | task = session.query(model.Task).get(id)
120 | if task is None:
121 | resp.status = falcon.HTTP_404
122 | return
123 |
124 | log = None
125 | if os.path.isfile(util.admin.taskDeploy.LOGFILE):
126 | with open(util.admin.taskDeploy.LOGFILE, 'r') as f:
127 | data = f.readlines()
128 | if re.search(r"^(\d*)", data[0]).group(1) == str(id):
129 | log = ''.join(data[1:])
130 |
131 | status = {
132 | 'id': task.id,
133 | 'log': log,
134 | 'deploy_date': task.deploy_date.isoformat()
135 | if task.deploy_date else None,
136 | 'deploy_status': task.deploy_status
137 | }
138 |
139 | req.context['result'] = status
140 | except SQLAlchemyError:
141 | session.rollback()
142 | raise
143 | finally:
144 | session.close()
145 |
--------------------------------------------------------------------------------
/endpoint/admin/taskMerge.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import json
3 | import git
4 | from lockfile import LockFile
5 | from sqlalchemy.exc import SQLAlchemyError
6 |
7 | from db import session
8 | import model
9 | import util
10 | from util import cache
11 |
12 |
13 | class TaskMerge(object):
14 |
15 | def on_post(self, req, resp, id):
16 | """
17 | Vraci JSON:
18 | {
19 | "result": "ok" | "error",
20 | "error": String
21 | }
22 |
23 | """
24 |
25 | try:
26 | user = req.context['user']
27 |
28 | # Kontrola existence ulohy
29 | task = session.query(model.Task).get(id)
30 | if task is None:
31 | req.context['result'] = 'Neexistujici uloha'
32 | resp.status = falcon.HTTP_404
33 | return
34 |
35 | # Kontrola existence git_branch a git_path
36 | if (task.git_path is None) or (task.git_branch is None):
37 | req.context['result'] = ('Uloha nema zadanou gitovskou vetev '
38 | 'nebo adresar')
39 | resp.status = falcon.HTTP_400
40 | return
41 |
42 | if task.git_branch == "master":
43 | req.context['result'] = 'Uloha je jiz ve vetvi master'
44 | resp.status = falcon.HTTP_400
45 | return
46 |
47 | wave = session.query(model.Wave).get(task.wave)
48 |
49 | # Merge mohou provadet pouze administratori a garant vlny
50 | if (not user.is_logged_in()) or (not user.is_admin() and
51 | user.id != wave.garant):
52 | req.context['result'] = 'Nedostatecna opravneni'
53 | resp.status = falcon.HTTP_400
54 | return
55 |
56 | # Kontrola zamku
57 | lock = util.lock.git_locked()
58 | if lock:
59 | req.context['result'] = ('GIT uzamcen zámkem '+lock +
60 | '\nNekdo momentalne provadi akci s '
61 | 'gitem, opakujte prosim akci za 20 '
62 | 'sekund.')
63 | resp.status = falcon.HTTP_409
64 | return
65 |
66 | try:
67 | mergeLock = LockFile(util.admin.taskMerge.LOCKFILE)
68 | mergeLock.acquire(60) # Timeout zamku je 1 minuta
69 |
70 | # Fetch repozitare
71 | repo = git.Repo(util.git.GIT_SEMINAR_PATH)
72 |
73 | if task.git_branch in repo.heads:
74 | # Cannot delete branch we are on
75 | repo.git.checkout("master")
76 | repo.git.branch('-D', task.git_branch)
77 |
78 | task.git_branch = 'master'
79 |
80 | session.commit()
81 | resp.status = falcon.HTTP_200
82 | req.context['result'] = {}
83 | cache.invalidate_cache(cache.get_key(None, "org", req.context['year'], 'admin_tasks'))
84 | finally:
85 | mergeLock.release()
86 | except SQLAlchemyError:
87 | session.rollback()
88 | raise
89 | finally:
90 | session.close()
91 |
--------------------------------------------------------------------------------
/endpoint/admin/waveDiff.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import git
3 | import os
4 | from sqlalchemy.exc import SQLAlchemyError
5 | from lockfile import LockFile
6 |
7 | from db import session
8 | import model
9 | import util
10 |
11 |
12 | class WaveDiff(object):
13 |
14 | def on_post(self, req, resp, id):
15 | try:
16 | user = req.context['user']
17 |
18 | if (not user.is_logged_in()) or (not user.is_org()):
19 | resp.status = falcon.HTTP_400
20 | return
21 |
22 | # Kontrola zamku
23 | lock = util.lock.git_locked()
24 | if lock:
25 | req.context['result'] = ('GIT uzamcen zamkem ' + lock +
26 | '\nNekdo momentalne provadi akci s '
27 | 'gitem, opakujte prosim akci za 20 '
28 | 'sekund.')
29 | resp.status = falcon.HTTP_409
30 | return
31 |
32 | pullLock = LockFile(util.admin.waveDiff.LOCKFILE)
33 | pullLock.acquire(60) # Timeout zamku je 1 minuta
34 |
35 | # Fetch
36 | repo = git.Repo(util.git.GIT_SEMINAR_PATH)
37 | repo.remotes.origin.fetch()
38 |
39 | # Ulohy ve vlne
40 | tasks = session.query(model.Task).\
41 | filter(model.Task.wave == id).all()
42 |
43 | # Diffujeme adresare uloh task.git_commit oproti HEAD
44 | for task in tasks:
45 | if ((not task.git_branch) or (not task.git_path) or
46 | (not task.git_commit)):
47 | task.deploy_status = 'default'
48 | continue
49 |
50 | # Checkout && pull vetve ve ktere je uloha
51 | repo.git.checkout(task.git_branch)
52 | repo.remotes.origin.pull()
53 |
54 | # Kontrola existence adresare ulohy
55 | if os.path.isdir(util.git.GIT_SEMINAR_PATH + task.git_path):
56 | hcommit = repo.head.commit
57 | diff = hcommit.diff(task.git_commit, paths=[task.git_path])
58 | if len(diff) > 0:
59 | task.deploy_status = 'diff'
60 | else:
61 | task.deploy_status = 'default'
62 |
63 | session.commit()
64 | req.context['result'] = {}
65 | except SQLAlchemyError:
66 | session.rollback()
67 | req.context['result'] = 'Nastala vyjimka backendu'
68 | raise
69 | finally:
70 | pullLock.release()
71 | session.close()
72 |
--------------------------------------------------------------------------------
/endpoint/csp.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import json
3 | import sys
4 | import traceback
5 |
6 | import util
7 |
8 | # Content-security policy reports of frontend
9 | # Every CSP report is forwarded to ksi-admin@fi.muni.cz.
10 | # This is testing solution, if a lot of spam occurs, some intelligence should
11 | # be added to this endpoint.
12 |
13 |
14 | class CSP(object):
15 |
16 | def on_post(self, req, resp):
17 | data = json.loads(req.stream.read().decode('utf-8'))
18 |
19 | # Ignore "about" violations caused by Disconnect plugin
20 | if "csp-report" not in data:
21 | req.context['result'] = {}
22 | resp.status = falcon.HTTP_200
23 | return
24 |
25 | if "blokced-uri" in data["csp-report"] and \
26 | data["csp-report"]["blocked-uri"] == "about":
27 | req.context['result'] = {}
28 | resp.status = falcon.HTTP_200
29 | return
30 |
31 | req.context['result'] = {}
32 | resp.status = falcon.HTTP_200
33 |
--------------------------------------------------------------------------------
/endpoint/diploma.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import falcon
4 | import magic
5 |
6 | import model
7 | from db import session
8 | from endpoint.admin.diploma import get_diploma_path
9 | from util.config import backend_url
10 |
11 |
12 | class Diploma:
13 | def on_get(self, req, resp, user):
14 | diplomas = session.query(model.Diploma)\
15 | .filter(model.Diploma.user_id == user)
16 |
17 | req.context['result'] = {
18 | 'diplomas': [{
19 | 'year': diploma.year_id,
20 | 'revoked': diploma.revoked,
21 | 'url': f"{backend_url()}/diplomas/{user}/{diploma.year_id}/show"
22 | } for diploma in diplomas]
23 | }
24 |
25 |
26 | class DiplomaDownload:
27 | def on_get(self, req, resp, user, year):
28 | user_id = user
29 | year_id = year
30 |
31 | diploma = session.query(model.Diploma)\
32 | .filter(model.Diploma.user_id == user_id, model.Diploma.year_id == year_id).first()
33 |
34 | if diploma is None:
35 | resp.status = falcon.HTTP_404
36 | req.context['result'] = {
37 | 'errors': [{
38 | 'status': '404',
39 | 'title': 'Not found',
40 | 'detail': 'No diploma for this user & year combination found'
41 | }]
42 | }
43 | return
44 |
45 | path = get_diploma_path(year_id, user_id)
46 | resp.content_type = magic.Magic(mime=True).from_file(f"{path}")
47 | resp.set_stream(open(path, 'rb'), os.path.getsize(path))
48 |
--------------------------------------------------------------------------------
/endpoint/feedback_email.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import util
4 |
5 |
6 | class FeedbackEmail(object):
7 |
8 | def on_post(self, req, resp):
9 | data = json.loads(req.stream.read().decode('utf-8'))
10 |
11 | if len(data['body']) == 0:
12 | return
13 |
14 | if 'email' not in data:
15 | data['email'] = util.config.ksi_conf()
16 |
17 | util.mail.send_feedback(data['body'].encode('utf-8'), data['email'])
18 |
19 | req.context['result'] = {'result': 'ok'}
20 |
--------------------------------------------------------------------------------
/endpoint/image.py:
--------------------------------------------------------------------------------
1 | import os
2 | import magic
3 | import falcon
4 | from sqlalchemy.exc import SQLAlchemyError
5 |
6 | from .profile import ALLOWED_MIME_TYPES
7 | from db import session
8 | import model
9 | import util
10 |
11 |
12 | class Image(object):
13 |
14 | def on_get(self, req, resp, context, id):
15 | if context == 'profile':
16 | try:
17 | user = session.query(model.User).\
18 | filter(model.User.id == int(id)).\
19 | first()
20 | except SQLAlchemyError:
21 | session.rollback()
22 | raise
23 |
24 | if not user or not user.profile_picture:
25 | resp.status = falcon.HTTP_404
26 | return
27 |
28 | image = user.profile_picture
29 | elif context == 'codeExecution':
30 | try:
31 | execution = session.query(model.CodeExecution).get(id)
32 | except SQLAlchemyError:
33 | session.rollback()
34 | raise
35 |
36 | if not execution:
37 | resp.status = falcon.HTTP_400
38 | return
39 |
40 | if not req.get_param('file'):
41 | resp.status = falcon.HTTP_400
42 | return
43 |
44 | image = os.path.join(
45 | util.programming.code_execution_dir(execution.user, execution.module),
46 | os.path.basename(req.get_param('file')))
47 |
48 | elif context == 'codeModule':
49 | if not req.get_param('file') or not req.get_param('module') or not req.get_param('user'):
50 | resp.status = falcon.HTTP_400
51 | return
52 |
53 | user_id = int(req.get_param('user'))
54 | module_id = int(req.get_param('module'))
55 | filename: str = os.path.basename(req.get_param('file'))
56 |
57 | if not filename.endswith(".png"):
58 | resp.status = falcon.HTTP_400
59 | return
60 |
61 | image = os.path.join(
62 | util.programming.code_execution_dir(user_id, module_id),
63 | filename
64 | )
65 |
66 | resp.cache_control = ('no-cache', )
67 |
68 | else:
69 | resp.status = falcon.HTTP_400
70 | return
71 |
72 | if not os.path.isfile(image):
73 | resp.status = falcon.HTTP_400
74 | return
75 |
76 | resp.content_type = magic.Magic(mime=True).from_file(image)
77 | resp.stream = open(image, 'rb', os.path.getsize(image))
78 |
--------------------------------------------------------------------------------
/endpoint/oauth2.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | from sqlalchemy.exc import SQLAlchemyError
3 |
4 | from db import session
5 | import auth
6 | import model
7 | import util
8 | from util import logger
9 | from util.logger import audit_log
10 |
11 |
12 | class Error:
13 | INVALID_REQUEST = 'invalid_request'
14 | UNAUTHORIZED_CLIENT = 'unauthorized_client'
15 | ACCOUNT_DISABLED = 'account_disabled'
16 |
17 |
18 | class Authorize(object):
19 |
20 | def _auth(self, req, resp):
21 | username = req.get_param('username')
22 | password = req.get_param('password')
23 |
24 | try:
25 | challenge = session.query(model.User).filter(
26 | model.User.email == username).first()
27 |
28 | if username and password and challenge:
29 | if not challenge.enabled:
30 | req.context['result'] = {'error': Error.ACCOUNT_DISABLED}
31 | resp.status = falcon.HTTP_400
32 | audit_log(
33 | scope="AUTH",
34 | user_id=challenge.id,
35 | message=f"Log-in to disabled account",
36 | message_meta={
37 | 'role': challenge.role,
38 | 'ip': req.context['source_ip']
39 | }
40 | )
41 | return
42 |
43 | if auth.check_password(password, challenge.password):
44 | req.context['result'] = auth.OAuth2Token(challenge).data
45 | else:
46 | audit_log(
47 | scope="AUTH",
48 | user_id=challenge.id,
49 | message=f"Incorrect password",
50 | message_meta={
51 | 'role': challenge.role,
52 | 'ip': req.context['source_ip']
53 | }
54 | )
55 | req.context['result'] = {'error': Error.UNAUTHORIZED_CLIENT}
56 | resp.status = falcon.HTTP_400
57 | else:
58 | logger.get_log().debug(f"User tried to login to an non-existing account or did not specify username or password")
59 | req.context['result'] = {'error': Error.UNAUTHORIZED_CLIENT}
60 | resp.status = falcon.HTTP_400
61 | except SQLAlchemyError:
62 | session.rollback()
63 | raise
64 |
65 | def _refresh(self, req, resp):
66 | refresh_token = req.get_param('refresh_token')
67 |
68 | try:
69 | token = session.query(model.Token).filter(
70 | model.Token.refresh_token == refresh_token).first()
71 |
72 | if token:
73 | session.delete(token)
74 | user = session.query(model.User).get(token.user)
75 | req.context['result'] = auth.OAuth2Token(user).data
76 | else:
77 | req.context['result'] = {'error': Error.UNAUTHORIZED_CLIENT}
78 | resp.status = falcon.HTTP_400
79 | except SQLAlchemyError:
80 | session.rollback()
81 | raise
82 |
83 | def on_post(self, req, resp):
84 | try:
85 | grant_type = req.get_param('grant_type')
86 | util.auth.update_tokens()
87 |
88 | if grant_type == 'password':
89 | self._auth(req, resp)
90 | elif grant_type == 'refresh_token':
91 | self._refresh(req, resp)
92 | else:
93 | resp.status = falcon.HTTP_400
94 |
95 | except SQLAlchemyError:
96 | session.rollback()
97 | raise
98 | finally:
99 | session.close()
100 |
101 |
102 | class Logout(object):
103 |
104 | def on_get(self, req, resp):
105 | try:
106 | if not req.context['user'].is_logged_in():
107 | return
108 |
109 | token = session.query(model.Token).\
110 | filter(model.Token.access_token == req.context['user'].token).\
111 | first()
112 |
113 | if not token:
114 | return
115 |
116 | session.delete(token)
117 | session.commit()
118 | except SQLAlchemyError:
119 | session.rollback()
120 | raise
121 | finally:
122 | session.close()
123 |
--------------------------------------------------------------------------------
/endpoint/registration.py:
--------------------------------------------------------------------------------
1 | import json
2 | import falcon
3 | import sys
4 | import traceback
5 | from sqlalchemy.exc import SQLAlchemyError
6 |
7 | from db import session
8 | import model
9 | import auth
10 | import util
11 |
12 |
13 | class Registration(object):
14 |
15 | def on_post(self, req, resp):
16 | data = json.loads(req.stream.read().decode('utf-8'))
17 |
18 | try:
19 | existing_user = session.query(model.User).\
20 | filter(model.User.email == data['email']).\
21 | first()
22 |
23 | if existing_user is not None:
24 | req.context['result'] = {'error': "Na tento e-mail je již někdo zaregistrovaný."}
25 | return
26 | except SQLAlchemyError:
27 | session.rollback()
28 | raise
29 |
30 | try:
31 | if 'nick_name' not in data:
32 | data['nick_name'] = ""
33 | user = model.User(
34 | email=data['email'],
35 | password=auth.get_hashed_password(data['password']),
36 | first_name=data['first_name'],
37 | last_name=data['last_name'],
38 | nick_name=data['nick_name'],
39 | sex=data['gender'],
40 | short_info=data["short_info"]
41 | )
42 | session.add(user)
43 | session.commit()
44 | except SQLAlchemyError:
45 | session.rollback()
46 | req.context['result'] = {
47 | 'error': "Nelze vytvořit uživatele, kontaktuj prosím orga."
48 | }
49 | raise
50 |
51 | try:
52 | profile = model.Profile(
53 | user_id=user.id,
54 | addr_street=data['addr_street'],
55 | addr_city=data['addr_city'],
56 | addr_zip=data['addr_zip'],
57 | addr_country=data['addr_country'].lower(),
58 | school_name=data['school_name'],
59 | school_street=data['school_street'],
60 | school_city=data['school_city'],
61 | school_zip=data['school_zip'],
62 | school_country=data['school_country'].lower(),
63 | school_finish=int(data['school_finish']),
64 | tshirt_size=data['tshirt_size'].upper(),
65 | referral=data.get('referral', "{}")
66 | )
67 | except BaseException:
68 | session.delete(user)
69 | session.commit()
70 | req.context['result'] = {
71 | 'error': "Nelze vytvořit profil, kontaktuj prosím orga."
72 | }
73 | raise
74 |
75 | try:
76 | session.add(profile)
77 | session.commit()
78 | except SQLAlchemyError:
79 | session.rollback()
80 | raise
81 |
82 | try:
83 | notify = model.UserNotify(
84 | user=user.id,
85 | auth_token=util.user_notify.new_token(),
86 | notify_eval=data['notify_eval'] if 'notify_eval' in data else True,
87 | notify_response=data['notify_response'] if 'notify_response' in data else True,
88 | notify_ksi=data['notify_ksi'] if 'notify_ksi' in data else True,
89 | notify_events=data['notify_events'] if 'notify_events' in data else True,
90 | )
91 | except BaseException:
92 | session.delete(profile)
93 | session.commit()
94 | session.delete(user)
95 | session.commit()
96 | req.context['result'] = {
97 | 'error': "Nelze vytvořit notifikační záznam, kontaktuj prosím orga."
98 | }
99 | raise
100 |
101 | try:
102 | session.add(notify)
103 | session.commit()
104 | except SQLAlchemyError:
105 | session.rollback()
106 | raise
107 |
108 | try:
109 | util.mail.send(
110 | user.email,
111 | f'{util.config.mail_subject_prefix()} Potvrzení registrace {util.config.seminar_name()}',
112 | f'Ahoj!
{util.config.mail_registration_welcome()} Nyní můžeš začít řešit naplno. '
113 | f'Stačí se přihlásit na {util.config.ksi_web()} pomocí e-mailu a zvoleného hesla. '
114 | 'Přejeme ti hodně úspěchů při řešení semináře!'
115 | )
116 | except SQLAlchemyError:
117 | exc_type, exc_value, exc_traceback = sys.exc_info()
118 | traceback.print_exception(exc_type, exc_value, exc_traceback,
119 | file=sys.stderr)
120 |
121 | session.close()
122 | req.context['result'] = {}
123 |
--------------------------------------------------------------------------------
/endpoint/robots.py:
--------------------------------------------------------------------------------
1 | import falcon
2 |
3 |
4 | class Robots(object):
5 | def on_head(self, req, resp):
6 | self.on_get(req=req, resp=resp)
7 | resp.body = ''
8 |
9 | def on_get(self, req, resp):
10 | resp.content_type = 'text/plain'
11 | resp.body = "User-agent: *\nDisallow: /"
12 |
--------------------------------------------------------------------------------
/endpoint/runcode.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import json
3 | from datetime import datetime
4 | from sqlalchemy.exc import SQLAlchemyError
5 | import traceback
6 |
7 | from db import session
8 | import model
9 | import util
10 |
11 |
12 | class RunCode(object):
13 |
14 | def on_post(self, req, resp, id):
15 | try:
16 | user = req.context['user']
17 | if not user.is_logged_in():
18 | resp.status = falcon.HTTP_401
19 | return
20 |
21 | data = json.loads(req.stream.read().decode('utf-8'))['content']
22 | module = session.query(model.Module).get(id)
23 |
24 | if not module:
25 | resp.status = falcon.HTTP_404
26 | return
27 |
28 | task_status = util.task.status(session.query(model.Task).
29 | get(module.task), user)
30 |
31 | if task_status == util.TaskStatus.LOCKED:
32 | resp.status = falcon.HTTP_403
33 | return
34 |
35 | execution = model.CodeExecution(
36 | module=module.id,
37 | user=user.id,
38 | code=data,
39 | result='error',
40 | time=datetime.utcnow(),
41 | report="",
42 | )
43 | session.add(execution)
44 | session.commit()
45 |
46 | reporter = util.programming.Reporter(max_size=50*1000)
47 | try:
48 | try:
49 | result = util.programming.run(module, user.id, data,
50 | execution.id, reporter)
51 | execution.result = result['result']
52 | req.context['result'] = result
53 | except (util.programming.ENoFreeBox,
54 | util.programming.EIsolateError,
55 | util.programming.ECheckError,
56 | util.programming.EMergeError) as e:
57 | reporter += str(e) + '\n'
58 | raise
59 | except Exception as e:
60 | req.context['result'] = {
61 | 'message': ('Kód se nepodařilo '
62 | 'spustit, kontaktujte organizátora.'),
63 | 'result': 'error',
64 | }
65 | reporter += traceback.format_exc()
66 |
67 | if user.is_org():
68 | req.context['result']['report'] = reporter.report_truncated
69 |
70 | execution.report = reporter.report_truncated # prevent database column size overflow
71 | session.commit()
72 |
73 | except SQLAlchemyError:
74 | session.rollback()
75 | raise
76 |
77 | finally:
78 | session.close()
79 |
--------------------------------------------------------------------------------
/endpoint/task.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | from sqlalchemy import func
3 | from sqlalchemy.exc import SQLAlchemyError
4 |
5 | from db import session
6 | import model
7 | import util
8 |
9 |
10 | class Task(object):
11 |
12 | def on_get(self, req, resp, id):
13 | try:
14 | user = req.context['user']
15 | task = session.query(model.Task).get(id)
16 |
17 | if task is None:
18 | req.context['result'] = {
19 | 'errors': [{
20 | 'status': '404',
21 | 'title': 'Not found',
22 | 'detail': 'Úloha s tímto ID neexistuje.'
23 | }]
24 | }
25 | resp.status = falcon.HTTP_404
26 | return
27 |
28 | if ((not user.is_logged_in()) or ((not user.is_org()) and
29 | (not user.is_tester()))):
30 | if not session.query(model.Wave).get(task.wave).public:
31 | req.context['result'] = {
32 | 'errors': [{
33 | 'status': '403',
34 | 'title': 'Forbidden',
35 | 'detail': 'Úloha s tímto ID je uzamčena.'
36 | }]
37 | }
38 | resp.status = falcon.HTTP_403
39 | return
40 |
41 | req.context['result'] = {
42 | 'task': util.task.to_json(
43 | task,
44 | prereq_obj=task.prerequisite_obj,
45 | user=user
46 | )
47 | }
48 | except SQLAlchemyError:
49 | session.rollback()
50 | raise
51 | finally:
52 | session.close()
53 |
54 |
55 | class Tasks(object):
56 |
57 | def on_get(self, req, resp):
58 | try:
59 | user = req.context['user']
60 |
61 | tasks = session.query(model.Task, model.Wave, model.Prerequisite).\
62 | outerjoin(model.Prerequisite,
63 | model.Prerequisite.id == model.Task.prerequisite).\
64 | join(model.Wave, model.Task.wave == model.Wave.id)
65 |
66 | if ((not user.is_logged_in()) or ((not user.is_org()) and
67 | not user.is_tester())):
68 | tasks = tasks.filter(model.Wave.public)
69 | tasks = tasks.filter(model.Wave.year == req.context['year']).all()
70 |
71 | adeadline = util.task.after_deadline()
72 | fsubmitted = util.task.fully_submitted(
73 | user.id,
74 | req.context['year']
75 | )
76 | corrected = util.task.corrected(user.id)
77 | autocorrected_full = util.task.autocorrected_full(user.id)
78 | task_max_points_dict = util.task.max_points_dict()
79 |
80 | req.context['result'] = {
81 | 'tasks': [
82 | util.task.to_json(
83 | task, prereq, user, adeadline, fsubmitted, wave,
84 | task.id in corrected, task.id in autocorrected_full,
85 | task_max_points=task_max_points_dict[task.id]
86 | )
87 | for (task, wave, prereq) in tasks
88 | ]
89 | }
90 | except SQLAlchemyError:
91 | session.rollback()
92 | raise
93 | finally:
94 | session.close()
95 |
96 |
97 | class TaskDetails(object):
98 |
99 | def on_get(self, req, resp, id):
100 | try:
101 | user = req.context['user']
102 | task = session.query(model.Task).get(id)
103 | if task is None:
104 | req.context['result'] = {
105 | 'errors': [{
106 | 'status': '404',
107 | 'title': 'Not found',
108 | 'detail': 'Úloha s tímto ID neexistuje.'
109 | }]
110 | }
111 | resp.status = falcon.HTTP_404
112 | return
113 | status = util.task.status(task, user)
114 |
115 | if status == util.TaskStatus.LOCKED:
116 | req.context['result'] = {
117 | 'errors': [{
118 | 'status': '403',
119 | 'title': 'Forbudden',
120 | 'detail': 'Úloha uzamčena.'
121 | }]
122 | }
123 | resp.status = falcon.HTTP_403
124 | return
125 |
126 | achievements = util.achievement.per_task(user.id, id)
127 | scores = util.task.points_per_module(id, user.id)
128 | best_scores = util.task.best_scores(id)
129 |
130 | comment_thread = util.task.comment_thread(id, user.id)
131 | thread_ids = {task.thread, comment_thread}
132 | threads = [
133 | session.query(model.Thread).get(thread_id)
134 | for thread_id in thread_ids if thread_id is not None
135 | ]
136 | posts = []
137 | for thread in threads:
138 | posts += thread.posts
139 |
140 | req.context['result'] = {
141 | 'taskDetails': util.task.details_to_json(
142 | task, user, status, achievements, best_scores,
143 | comment_thread
144 | ),
145 | 'modules': [
146 | util.module.to_json(module, user.id)
147 | for module in task.modules
148 | ],
149 | 'moduleScores': [
150 | util.module.score_to_json(score)
151 | for score in scores if score.points is not None
152 | ],
153 | 'achievements': [
154 | util.achievement.to_json(achievement)
155 | for achievement in achievements
156 | ],
157 | 'userScores': [
158 | util.task.best_score_to_json(best_score)
159 | for best_score in best_scores
160 | ],
161 | 'threads': [
162 | util.thread.to_json(thread, user.id)
163 | for thread in threads
164 | ],
165 | 'threadDetails': [
166 | util.thread.details_to_json(thread)
167 | for thread in threads
168 | ],
169 | 'posts': [
170 | util.post.to_json(post, user.id)
171 | for post in posts
172 | ]
173 | }
174 | except SQLAlchemyError:
175 | session.rollback()
176 | raise
177 | finally:
178 | session.close()
179 |
--------------------------------------------------------------------------------
/endpoint/unsubscribe.py:
--------------------------------------------------------------------------------
1 | import json
2 | import falcon
3 | from sqlalchemy.exc import SQLAlchemyError
4 |
5 | from db import session
6 | import model
7 | import util
8 |
9 |
10 | class Unsubscribe(object):
11 |
12 | def on_get(self, req, resp, id):
13 | return self.on_post(req, resp, id)
14 |
15 | def on_post(self, req, resp, id):
16 | """
17 | Params:
18 | - ?token=auth_token
19 | - ?type=eval|response|ksi|events|all
20 | """
21 | try:
22 | resp.content_type = 'text/html; charset=utf-8'
23 |
24 | notify = util.user_notify.get(id)
25 | if req.get_param('token') != notify.auth_token:
26 | resp.body = 'Chyba 403: špatný autorizační token!'
27 | resp.status = falcon.HTTP_403
28 | return
29 |
30 | valid_types = ['eval', 'response', 'ksi', 'events', 'all']
31 | u_type = req.get_param('type')
32 |
33 | if u_type not in valid_types:
34 | resp.body = 'Chyba 400: neplatný typ zprávy!'
35 | resp.status = falcon.HTTP_400
36 | return
37 |
38 | if u_type == 'eval':
39 | notify.notify_eval = False
40 | elif u_type == 'response':
41 | notify.notify_response = False
42 | elif u_type == 'ksi':
43 | notify.notify_ksi = False
44 | elif u_type == 'events':
45 | notify.notify_events = False
46 | elif u_type == 'all':
47 | notify.notify_eval = False
48 | notify.notify_response = False
49 | notify.notify_ksi = False
50 | notify.notify_events = False
51 |
52 | session.commit()
53 | resp.body = 'Úspěšně odhlášeno.'
54 |
55 | resp.body += '
Aktuální stav notifikací pro adresu: %s:' % (
56 | session.query(model.User).get(id).email
57 | )
58 | resp.body += ''
59 | resp.body += '- Notifikovat o opravení mého řešení: %s.
' % (
60 | 'ano' if notify.notify_eval else 'ne'
61 | )
62 | resp.body += '- Notifikovat o reakci na můj komentář: %s.
' % (
63 | 'ano' if notify.notify_response else 'ne'
64 | )
65 | resp.body += '- Notifikovat o průběhu semináře (vydání nové vlny, ...): %s.
' % (
66 | 'ano' if notify.notify_ksi else 'ne'
67 | )
68 | resp.body += '- Zasílat pozvánky na spřátelené akce (InterLoS, InterSoB, ...): %s.
' % (
69 | 'ano' if notify.notify_events else 'ne'
70 | )
71 | resp.body += '
'
72 | resp.body += 'Další změny je možné provést v nastavení tvého profilu na webu'
73 |
74 | except SQLAlchemyError:
75 | resp.body = 'Chyba 500: nastala výjimka, kontaktuj orga!'
76 | session.rollback()
77 | raise
78 | finally:
79 | session.close()
80 |
--------------------------------------------------------------------------------
/endpoint/wave.py:
--------------------------------------------------------------------------------
1 | import falcon
2 | import json
3 | from sqlalchemy.exc import SQLAlchemyError
4 | import dateutil.parser
5 |
6 | from db import session
7 | import model
8 | import util
9 | import datetime
10 |
11 |
12 | class Wave(object):
13 |
14 | def on_get(self, req, resp, id):
15 | try:
16 | user = req.context['user']
17 | wave = session.query(model.Wave).get(id)
18 |
19 | if wave is None:
20 | resp.status = falcon.HTTP_404
21 | return
22 |
23 | req.context['result'] = {'wave': util.wave.to_json(wave)}
24 |
25 | except SQLAlchemyError:
26 | session.rollback()
27 | raise
28 | finally:
29 | session.close()
30 |
31 | # UPDATE vlny
32 | def on_put(self, req, resp, id):
33 | try:
34 | user = req.context['user']
35 |
36 | if (not user.is_logged_in()) or (not user.is_org()):
37 | resp.status = falcon.HTTP_400
38 | return
39 |
40 | data = json.loads(req.stream.read().decode('utf-8'))['wave']
41 |
42 | wave = session.query(model.Wave).get(id)
43 | if wave is None:
44 | resp.status = falcon.HTTP_404
45 | return
46 |
47 | # Menit vlnu muze jen ADMIN nebo aktualni GARANT vlny.
48 | if not user.is_admin() and user.id != wave.garant:
49 | resp.status = falcon.HTTP_400
50 | return
51 |
52 | wave.index = data['index']
53 | wave.caption = data['caption']
54 | if data['time_published']:
55 | wave.time_published = dateutil.parser.parse(data['time_published'])
56 | wave.garant = data['garant']
57 |
58 | session.commit()
59 | except SQLAlchemyError:
60 | session.rollback()
61 | raise
62 | finally:
63 | session.close()
64 |
65 | self.on_get(req, resp, id)
66 |
67 | # Smazani vlny
68 | def on_delete(self, req, resp, id):
69 | try:
70 | user = req.context['user']
71 |
72 | # Vlnu mohou smazat jen admini
73 | if (not user.is_logged_in()) or (not user.is_admin()):
74 | resp.status = falcon.HTTP_400
75 | return
76 |
77 | wave = session.query(model.Wave).get(id)
78 | if wave is None:
79 | resp.status = falcon.HTTP_404
80 | return
81 |
82 | # Smazat lze jen neprazdnou vlnu.
83 | tasks_cnt = session.query(model.Task).\
84 | filter(model.Task.wave == wave.id).\
85 | count()
86 |
87 | if tasks_cnt > 0:
88 | resp.status = falcon.HTTP_403
89 | return
90 |
91 | session.delete(wave)
92 | session.commit()
93 | req.context['result'] = {}
94 |
95 | except SQLAlchemyError:
96 | session.rollback()
97 | raise
98 | finally:
99 | session.close()
100 |
101 |
102 | class Waves(object):
103 |
104 | def on_get(self, req, resp):
105 | try:
106 | user = req.context['user']
107 | can_see_unrealesed = user.is_logged_in() and (user.is_org() or user.is_tester())
108 | waves = session.query(model.Wave).\
109 | filter(model.Wave.year == req.context['year']).all()
110 |
111 | max_points = util.task.max_points_wave_dict()
112 |
113 | req.context['result'] = {
114 | 'waves': [
115 | util.wave.to_json(wave, max_points[wave.id])
116 | for wave in waves if (can_see_unrealesed or wave.time_published < datetime.datetime.now())
117 | ]
118 | }
119 |
120 | except SQLAlchemyError:
121 | session.rollback()
122 | raise
123 | finally:
124 | session.close()
125 |
126 | # Vytvoreni nove vlny
127 | def on_post(self, req, resp):
128 | try:
129 | user = req.context['user']
130 | year = req.context['year']
131 |
132 | # Vytvorit novou vlnu mohou jen admini.
133 | if (not user.is_logged_in()) or (not user.is_admin()):
134 | resp.status = falcon.HTTP_400
135 | return
136 |
137 | data = json.loads(req.stream.read().decode('utf-8'))['wave']
138 |
139 | wave = model.Wave(
140 | year=year,
141 | index=data['index'],
142 | caption=data['caption'],
143 | garant=data['garant'],
144 | time_published=dateutil.parser.parse(data['time_published'])
145 | )
146 |
147 | session.add(wave)
148 | session.commit()
149 | req.context['result'] = {'wave': util.wave.to_json(wave)}
150 | except SQLAlchemyError:
151 | session.rollback()
152 | raise
153 | finally:
154 | session.close()
155 |
--------------------------------------------------------------------------------
/endpoint/year.py:
--------------------------------------------------------------------------------
1 | import json
2 | import falcon
3 | from sqlalchemy.exc import SQLAlchemyError
4 |
5 | from db import session
6 | import model
7 | import util
8 |
9 |
10 | class Year(object):
11 |
12 | def on_get(self, req, resp, id):
13 | try:
14 | year = session.query(model.Year).get(id)
15 |
16 | if year is None:
17 | resp.status = falcon.HTTP_404
18 | return
19 |
20 | req.context['result'] = {'year': util.year.to_json(year)}
21 |
22 | except SQLAlchemyError:
23 | session.rollback()
24 | raise
25 | finally:
26 | session.close()
27 |
28 | # UPDATE rocniku
29 | def on_put(self, req, resp, id):
30 | try:
31 | user = req.context['user']
32 |
33 | # Upravovat rocniky mohou jen ADMINI
34 | if (not user.is_logged_in()) or (not user.is_admin()):
35 | resp.status = falcon.HTTP_400
36 | return
37 |
38 | data = json.loads(req.stream.read().decode('utf-8'))['year']
39 |
40 | year = session.query(model.Year).get(id)
41 | if year is None:
42 | resp.status = falcon.HTTP_404
43 | return
44 |
45 | year.id = data['index']
46 | year.year = data['year']
47 | year.sealed = data['sealed']
48 | year.point_pad = data['point_pad']
49 |
50 | # Aktualizace aktivnich orgu
51 | orgs = session.query(model.ActiveOrg).\
52 | filter(model.ActiveOrg.year == year.id).all()
53 |
54 | for i in range(len(orgs) - 1, -1, -1):
55 | if str(orgs[i].org) in data['active_orgs']:
56 | data['active_orgs'].remove(str(orgs[i].org))
57 | del orgs[i]
58 |
59 | for org in orgs:
60 | session.delete(org)
61 |
62 | for user_id in data['active_orgs']:
63 | org = model.ActiveOrg(org=user_id, year=year.id)
64 | session.add(org)
65 |
66 | session.commit()
67 |
68 | except SQLAlchemyError:
69 | session.rollback()
70 | raise
71 | finally:
72 | session.close()
73 |
74 | self.on_get(req, resp, id)
75 |
76 | # Smazani rocniku
77 | def on_delete(self, req, resp, id):
78 | try:
79 | user = req.context['user']
80 |
81 | if (not user.is_logged_in()) or (not user.is_admin()):
82 | resp.status = falcon.HTTP_400
83 | return
84 |
85 | year = session.query(model.Year).get(id)
86 | if year is None:
87 | resp.status = falcon.HTTP_404
88 | return
89 |
90 | # Odstranit lze jen neprazdny rocnik
91 | waves_cnt = session.query(model.Wave).\
92 | filter(model.Wave.year == year.id).\
93 | count()
94 |
95 | if waves_cnt > 0:
96 | resp.status = falcon.HTTP_403
97 | return
98 |
99 | session.delete(year)
100 | session.commit()
101 | req.context['result'] = {}
102 |
103 | except SQLAlchemyError:
104 | session.rollback()
105 | raise
106 | finally:
107 | session.close()
108 |
109 |
110 | class Years(object):
111 |
112 | def on_head(self, req, resp):
113 | self.on_get(req=req, resp=resp)
114 | resp.body = ''
115 |
116 | def on_get(self, req, resp):
117 | try:
118 | years = session.query(model.Year).all()
119 |
120 | sum_points = util.task.max_points_year_dict()
121 |
122 | req.context['result'] = {
123 | 'years': [
124 | util.year.to_json(year, sum_points[year.id])
125 | for year in years
126 | ]
127 | }
128 |
129 | except SQLAlchemyError:
130 | session.rollback()
131 | raise
132 | finally:
133 | session.close()
134 |
135 | # Vytvoreni noveho rocniku
136 | def on_post(self, req, resp):
137 | try:
138 | user = req.context['user']
139 |
140 | # Vytvoret novy rocnik mohou jen ADMINI
141 | if (not user.is_logged_in()) or (not user.is_admin()):
142 | resp.status = falcon.HTTP_400
143 | return
144 |
145 | data = json.loads(req.stream.read().decode('utf-8'))['year']
146 |
147 | year = model.Year(
148 | id=data['index'],
149 | year=data['year'],
150 | sealed=data['sealed'] if data['sealed'] else False,
151 | point_pad=data['point_pad']
152 | )
153 |
154 | session.add(year)
155 | session.commit()
156 |
157 | if 'active_orgs' in data:
158 | for user_id in data['active_orgs']:
159 | org = model.ActiveOrg(org=user_id, year=year.id)
160 | session.add(org)
161 |
162 | session.commit()
163 |
164 | req.context['result'] = {'year': util.year.to_json(year)}
165 |
166 | except SQLAlchemyError:
167 | session.rollback()
168 | raise
169 | finally:
170 | session.close()
171 |
--------------------------------------------------------------------------------
/gunicorn_cfg.py.example:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | bind = '127.0.0.1:3030'
4 | workers = 4
5 | timeout = 60
6 | capture_output = True
7 |
8 |
9 | def pre_request(worker, req):
10 | if req.path.startswith('/content/'):
11 | req.query = 'path=' + req.path[9:]
12 | req.path = '/content'
13 | if req.path.startswith('/taskContent/'):
14 | parts = req.path.split("/")
15 | req.query = 'path=' + '/'.join(parts[4:])
16 | req.path = '/task-content/' + parts[2] + '/' + (parts[3] if len(parts) > 3 else '')
17 |
--------------------------------------------------------------------------------
/init-makedirs.sh:
--------------------------------------------------------------------------------
1 | #/bin/bash
2 | # This script makes filesystem structure necessarry for ksi-backend
3 | # to work. It is intended to be run at (and only at) initialization on
4 | # a new server. However, if you run this script on running server,
5 | # it just does nothing.
6 |
7 | # Do not forget to checkout repository 'seminar' into data/seminar (write access required).
8 | # Do not forget to checkout repository 'module_lib' into data/module_lib (read access in enough).
9 |
10 | cd "$(dirname "$(realpath "$0")")" || { echo "ERR: Cannot cd to script dir"; exit 1; }
11 |
12 | echo -n "[*] Making data directories..."
13 | mkdir -p data/code_executions
14 | mkdir -p data/content/achievements data/content/articles
15 | mkdir -p data/images
16 | mkdir -p data/modules
17 | mkdir -p data/seminar
18 | mkdir -p data/submissions
19 | mkdir -p data/task-content
20 | echo " done"
21 |
--------------------------------------------------------------------------------
/model/__init__.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.ext.declarative import declarative_base
2 |
3 | Base = declarative_base()
4 |
5 | from model.year import Year
6 | from model.wave import Wave
7 | from model.config import Config
8 | from model.profile import Profile
9 | from model.user import User
10 | from model.article import Article
11 | from model.achievement import Achievement
12 | from model.thread import Thread, ThreadVisit
13 | from model.post import Post
14 | from model.prerequisite import Prerequisite, PrerequisiteType
15 | from model.task import Task, SolutionComment
16 | from model.module import Module, ModuleType
17 | from model.module_custom import ModuleCustom
18 | from model.token import Token
19 | from model.user_achievement import UserAchievement
20 | from model.mail_easteregg import MailEasterEgg
21 | from model.feedback_recipients import FeedbackRecipient
22 | from model.programming import CodeExecution
23 | from model.evaluation import Evaluation
24 | from model.submitted import SubmittedFile, SubmittedCode
25 | from model.active_orgs import ActiveOrg
26 | from model.feedback import Feedback
27 | from model.user_notify import UserNotify
28 | from model.diploma import Diploma
29 | from model.cache import Cache
30 |
--------------------------------------------------------------------------------
/model/achievement.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, String, ForeignKey
2 |
3 | from . import Base
4 | from .year import Year
5 |
6 |
7 | class Achievement(Base):
8 | __tablename__ = 'achievements'
9 | __table_args__ = {
10 | 'mysql_engine': 'InnoDB',
11 | 'mysql_charset': 'utf8mb4'
12 | }
13 |
14 | id = Column(Integer, primary_key=True)
15 | title = Column(String(255), nullable=False)
16 | picture = Column(String(128), nullable=False, unique=False)
17 | description = Column(String(200), nullable=True)
18 | year = Column(Integer, ForeignKey(Year.id), nullable=True)
19 |
--------------------------------------------------------------------------------
/model/active_orgs.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, ForeignKey
2 |
3 | from . import Base
4 | from .user import User
5 | from .year import Year
6 |
7 |
8 | class ActiveOrg(Base):
9 | __tablename__ = 'active_orgs'
10 | __table_args__ = {
11 | 'mysql_engine': 'InnoDB',
12 | 'mysql_charset': 'utf8mb4'
13 | }
14 |
15 | org = Column(Integer,
16 | ForeignKey(User.id, ondelete='CASCADE'),
17 | primary_key=True,
18 | nullable=False)
19 |
20 | year = Column(Integer,
21 | ForeignKey(Year.id, ondelete='CASCADE'),
22 | primary_key=True,
23 | nullable=False)
24 |
--------------------------------------------------------------------------------
/model/article.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, Integer, String, Text, ForeignKey, text, Boolean
4 | from sqlalchemy.types import TIMESTAMP
5 |
6 | from . import Base
7 | from .year import Year
8 |
9 |
10 | class Article(Base):
11 | __tablename__ = 'articles'
12 | __table_args__ = {
13 | 'mysql_engine': 'InnoDB',
14 | 'mysql_charset': 'utf8mb4'
15 | }
16 |
17 | id = Column(Integer, primary_key=True, nullable=False)
18 | author = Column(Integer, ForeignKey('users.id'), nullable=False)
19 | title = Column(String(255), nullable=False)
20 | body = Column(Text)
21 | picture = Column(String(255))
22 | time_created = Column(TIMESTAMP, default=datetime.datetime.utcnow,
23 | server_default=text('CURRENT_TIMESTAMP'))
24 | published = Column(Boolean)
25 | year = Column(Integer, ForeignKey(Year.id), nullable=False)
26 | resource = Column(String(512), nullable=True)
27 |
--------------------------------------------------------------------------------
/model/audit_log.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, Integer, ForeignKey, String, TIMESTAMP, text, Text
4 | from . import Base
5 | from .user import User
6 | from .year import Year
7 |
8 | class AuditLog(Base):
9 | __tablename__ = 'audit_logs'
10 | __table_args__ = {
11 | 'mysql_engine': 'InnoDB',
12 | 'mysql_charset': 'utf8mb4'
13 | }
14 |
15 | id = Column(Integer, primary_key=True)
16 | created = Column(TIMESTAMP, nullable=False,
17 | default=datetime.datetime.utcnow,
18 | server_default=text('CURRENT_TIMESTAMP'))
19 | user_id = Column(Integer,
20 | ForeignKey(User.id, ondelete='CASCADE'),
21 | nullable=True)
22 | year_id = Column(Integer,
23 | ForeignKey(Year.id, ondelete='CASCADE'),
24 | nullable=True)
25 | scope = Column(String(50), nullable=True)
26 | line = Column(Text, nullable=False)
27 | line_meta = Column(Text, nullable=True)
28 |
--------------------------------------------------------------------------------
/model/cache.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, String, Text, TIMESTAMP
2 |
3 | from . import Base
4 |
5 |
6 | class Cache(Base):
7 | __tablename__ = 'cache'
8 | __table_args__ = {
9 | 'mysql_engine': 'InnoDB',
10 | 'mysql_charset': 'utf8mb4'
11 | }
12 |
13 | key = Column(String(220), primary_key=True, nullable=False)
14 | value = Column(Text(), nullable=False)
15 | expires = Column(TIMESTAMP, nullable=False)
16 |
--------------------------------------------------------------------------------
/model/config.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, String, Boolean, Text
2 |
3 | from . import Base
4 |
5 |
6 | class Config(Base):
7 | __tablename__ = 'config'
8 | __table_args__ = {
9 | 'mysql_engine': 'InnoDB',
10 | 'mysql_charset': 'utf8mb4'
11 | }
12 |
13 | key = Column(String(100), primary_key=True, nullable=False)
14 | value = Column(Text(), nullable=True)
15 | secret = Column(Boolean(), nullable=False, default=False)
16 |
--------------------------------------------------------------------------------
/model/diploma.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, Integer, ForeignKey, Boolean
4 | from . import Base
5 | from .user import User
6 | from .year import Year
7 |
8 |
9 | class Diploma(Base):
10 | __tablename__ = 'diplomas'
11 | __table_args__ = {
12 | 'mysql_engine': 'InnoDB',
13 | 'mysql_charset': 'utf8mb4'
14 | }
15 |
16 | user_id = Column(Integer, ForeignKey(User.id, ondelete='CASCADE'),
17 | primary_key=True)
18 | year_id = Column(Integer, ForeignKey(Year.id, ondelete='CASCADE'),
19 | primary_key=True)
20 | revoked = Column(Boolean, nullable=False, default=False)
21 |
--------------------------------------------------------------------------------
/model/evaluation.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from sqlalchemy import (Column, Integer, Text, ForeignKey, text, DECIMAL,
3 | Boolean)
4 | from sqlalchemy.types import TIMESTAMP
5 |
6 | from . import Base
7 | from .user import User
8 | from .module import Module
9 |
10 |
11 | class Evaluation(Base):
12 | __tablename__ = 'evaluations'
13 | __table_args__ = (
14 | {
15 | 'mysql_engine': 'InnoDB',
16 | 'mysql_charset': 'utf8mb4',
17 | })
18 |
19 | id = Column(Integer, primary_key=True)
20 | user = Column(Integer, ForeignKey(User.id, ondelete='CASCADE'),
21 | nullable=False)
22 | module = Column(Integer, ForeignKey(Module.id), nullable=False)
23 | evaluator = Column(Integer, ForeignKey(User.id))
24 | points = Column(DECIMAL(precision=10, scale=1, asdecimal=False),
25 | nullable=False, default=0)
26 | ok = Column(Boolean, nullable=False, default=False,
27 | server_default=text('FALSE'))
28 | cheat = Column(Boolean, nullable=False, default=False)
29 | full_report = Column(Text, nullable=False, default="")
30 | time = Column(TIMESTAMP, default=datetime.datetime.utcnow,
31 | server_default=text('CURRENT_TIMESTAMP'))
32 |
--------------------------------------------------------------------------------
/model/feedback.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, ForeignKey, Text, text
2 | from sqlalchemy.types import TIMESTAMP
3 | import datetime
4 |
5 | from . import Base
6 | from .user import User
7 | from .task import Task
8 |
9 |
10 | class Feedback(Base):
11 | __tablename__ = 'feedbacks'
12 | __table_args__ = {
13 | 'mysql_engine': 'InnoDB',
14 | 'mysql_charset': 'utf8mb4'
15 | }
16 |
17 | user = Column(
18 | Integer,
19 | ForeignKey(User.id, ondelete='CASCADE', onupdate='NO ACTION'),
20 | primary_key=True,
21 | nullable=False,
22 | )
23 | task = Column(
24 | Integer,
25 | ForeignKey(Task.id, ondelete='CASCADE', onupdate='NO ACTION'),
26 | primary_key=True,
27 | nullable=False,
28 | )
29 | content = Column(Text, nullable=False)
30 | lastUpdated = Column(TIMESTAMP, default=datetime.datetime.utcnow,
31 | server_default=text('CURRENT_TIMESTAMP'))
32 |
--------------------------------------------------------------------------------
/model/feedback_recipients.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, String
2 |
3 | from . import Base
4 |
5 |
6 | class FeedbackRecipient(Base):
7 | __tablename__ = 'feedback_recipients'
8 | __table_args__ = {
9 | 'mysql_engine': 'InnoDB',
10 | 'mysql_charset': 'utf8mb4'
11 | }
12 |
13 | email = Column(String(150), primary_key=True)
14 |
--------------------------------------------------------------------------------
/model/mail_easteregg.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, Text
2 |
3 | from . import Base
4 |
5 |
6 | class MailEasterEgg(Base):
7 | __tablename__ = 'mail_eastereggs'
8 | __table_args__ = {
9 | 'mysql_engine': 'InnoDB',
10 | 'mysql_charset': 'utf8mb4'
11 | }
12 |
13 | id = Column(Integer, primary_key=True, nullable=False)
14 | body = Column(Text, nullable=False)
15 |
--------------------------------------------------------------------------------
/model/module.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import (Column, Integer, SmallInteger, String, Text, Enum,
2 | Boolean, ForeignKey, text, DECIMAL)
3 |
4 | from . import Base
5 | from .task import Task
6 |
7 |
8 | class ModuleType(Enum):
9 | GENERAL = "general"
10 | PROGRAMMING = "programming"
11 | QUIZ = "quiz"
12 | SORTABLE = "sortable"
13 | TEXT = "text"
14 |
15 |
16 | class Module(Base):
17 | __tablename__ = 'modules'
18 | __table_args__ = {
19 | 'mysql_engine': 'InnoDB',
20 | 'mysql_charset': 'utf8mb4',
21 | }
22 |
23 | id = Column(Integer, primary_key=True)
24 | task = Column(Integer, ForeignKey(Task.id, ondelete='CASCADE'),
25 | nullable=False)
26 | type = Column(Enum(ModuleType.GENERAL, ModuleType.PROGRAMMING,
27 | ModuleType.QUIZ, ModuleType.SORTABLE, ModuleType.TEXT),
28 | nullable=False)
29 | name = Column(String(255), nullable=False)
30 | description = Column(Text)
31 | max_points = Column(DECIMAL(precision=10, scale=1, asdecimal=False),
32 | nullable=False, default=0)
33 | autocorrect = Column(Boolean, nullable=False, default=False,
34 | server_default=text('FALSE'))
35 | order = Column(SmallInteger, nullable=False, default=1, server_default='1')
36 | bonus = Column(Boolean, nullable=False, default=False,
37 | server_default=text('FALSE'))
38 | custom = Column(Boolean, nullable=False, default=False)
39 | action = Column(Text)
40 | data = Column(Text)
41 |
--------------------------------------------------------------------------------
/model/module_custom.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import (Column, Integer, SmallInteger, String, Text, Enum,
2 | Boolean, ForeignKey, text, DECIMAL)
3 |
4 | from . import Base
5 | from .module import Module
6 | from .user import User
7 |
8 |
9 | class ModuleCustom(Base):
10 | __tablename__ = 'modules_custom'
11 | __table_args__ = {
12 | 'mysql_engine': 'InnoDB',
13 | 'mysql_charset': 'utf8mb4',
14 | }
15 |
16 | module = Column(
17 | Integer,
18 | ForeignKey(Module.id, ondelete='CASCADE', onupdate='NO ACTION'),
19 | primary_key=True,
20 | nullable=False,
21 | )
22 | user = Column(
23 | Integer,
24 | ForeignKey(User.id, ondelete='CASCADE', onupdate='NO ACTION'),
25 | primary_key=True,
26 | nullable=False,
27 | )
28 | description = Column(Text, nullable=True)
29 | description_replace = Column(Text, nullable=True)
30 | data = Column(Text, nullable=True)
31 | error = Column(Text, nullable=True)
32 |
--------------------------------------------------------------------------------
/model/post.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import (Column, Integer, String, DateTime, Text, ForeignKey,
4 | text)
5 | from sqlalchemy.types import TIMESTAMP
6 | from sqlalchemy.orm import relationship
7 |
8 | from . import Base
9 | from .thread import Thread
10 | from .user import User
11 |
12 |
13 | class Post(Base):
14 | __tablename__ = 'posts'
15 | __table_args__ = {
16 | 'mysql_engine': 'InnoDB',
17 | 'mysql_charset': 'utf8mb4'
18 | }
19 |
20 | id = Column(Integer, primary_key=True)
21 | thread = Column(Integer, ForeignKey(Thread.id, ondelete='CASCADE'),
22 | nullable=False)
23 | author = Column(Integer, ForeignKey(User.id), nullable=False)
24 | body = Column(Text, nullable=False)
25 | published_at = Column(TIMESTAMP, nullable=False,
26 | default=datetime.datetime.utcnow,
27 | server_default=text('CURRENT_TIMESTAMP'))
28 | parent = Column(Integer, ForeignKey(__tablename__ + '.id',
29 | ondelete='CASCADE'))
30 |
31 | reactions = relationship('Post')
32 |
--------------------------------------------------------------------------------
/model/prerequisite.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, String, Enum, ForeignKey
2 | from sqlalchemy.orm import relationship
3 |
4 | from . import Base
5 | from .task import Task
6 |
7 |
8 | class PrerequisiteType:
9 | ATOMIC = 'ATOMIC'
10 | AND = 'AND'
11 | OR = 'OR'
12 |
13 |
14 | class Prerequisite(Base):
15 | __tablename__ = 'prerequisities'
16 | __table_args__ = {
17 | 'mysql_engine': 'InnoDB',
18 | 'mysql_charset': 'utf8mb4'
19 | }
20 |
21 | id = Column(Integer, primary_key=True)
22 | type = Column(Enum(PrerequisiteType.ATOMIC, PrerequisiteType.AND,
23 | PrerequisiteType.OR),
24 | nullable=False, default=PrerequisiteType.ATOMIC,
25 | server_default=PrerequisiteType.ATOMIC)
26 | parent = Column(Integer, ForeignKey(__tablename__ + '.id',
27 | ondelete='CASCADE'), nullable=True)
28 | task = Column(Integer, ForeignKey(Task.id), nullable=True)
29 |
30 | children = relationship(
31 | 'Prerequisite',
32 | primaryjoin='Prerequisite.parent == Prerequisite.id'
33 | )
34 |
--------------------------------------------------------------------------------
/model/profile.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, Integer, String, Boolean, Enum, ForeignKey
4 | from sqlalchemy.types import TIMESTAMP
5 | from sqlalchemy.orm import relationship
6 |
7 | from . import Base
8 | from .user import User
9 |
10 |
11 | class Profile(Base):
12 | __tablename__ = 'profiles'
13 | __table_args__ = {
14 | 'mysql_engine': 'InnoDB',
15 | 'mysql_charset': 'utf8mb4'
16 | }
17 |
18 | user_id = Column(Integer, ForeignKey(User.id, ondelete='CASCADE'),
19 | primary_key=True)
20 | addr_street = Column(String(255), nullable=False)
21 | addr_city = Column(String(255), nullable=False)
22 | addr_zip = Column(String(20), nullable=False)
23 | addr_country = Column(String(255), nullable=False)
24 | school_name = Column(String(255), nullable=False)
25 | school_street = Column(String(255), nullable=False)
26 | school_city = Column(String(255), nullable=False)
27 | school_zip = Column(String(20), nullable=False)
28 | school_country = Column(String(255), nullable=False)
29 | school_finish = Column(Integer, nullable=False)
30 | tshirt_size = Column(Enum('XS', 'S', 'M', 'L', 'XL', 'NA'), nullable=False)
31 | referral = Column(String(4096), nullable=True)
32 |
--------------------------------------------------------------------------------
/model/programming.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, String, Text, ForeignKey, text, Enum
2 | from sqlalchemy.types import TIMESTAMP
3 | import datetime
4 |
5 | from . import Base
6 | from .module import Module
7 | from .user import User
8 |
9 |
10 | class CodeExecution(Base):
11 | __tablename__ = 'code_executions'
12 | __table_args__ = {
13 | 'mysql_engine': 'InnoDB',
14 | 'mysql_charset': 'utf8mb4',
15 | }
16 |
17 | id = Column(Integer, primary_key=True)
18 | module = Column(Integer, ForeignKey(Module.id, ondelete='CASCADE'),
19 | nullable=False)
20 | user = Column(Integer, ForeignKey(User.id, ondelete='CASCADE'),
21 | nullable=False)
22 | code = Column(Text)
23 | result = Column(Enum('ok', 'error'))
24 | time = Column(TIMESTAMP, default=datetime.datetime.utcnow(),
25 | server_default=text('CURRENT_TIMESTAMP'))
26 | report = Column(Text)
27 |
--------------------------------------------------------------------------------
/model/submitted.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, String, Text, ForeignKey
2 |
3 | from . import Base
4 | from .evaluation import Evaluation
5 |
6 |
7 | class SubmittedFile(Base):
8 | __tablename__ = 'submitted_files'
9 | __table_args__ = (
10 | {
11 | 'mysql_engine': 'InnoDB',
12 | 'mysql_charset': 'utf8mb4',
13 | })
14 |
15 | id = Column(Integer, primary_key=True)
16 | evaluation = Column(Integer, ForeignKey(Evaluation.id, ondelete='CASCADE'),
17 | nullable=False)
18 | mime = Column(String(255))
19 | path = Column(String(255), nullable=False)
20 |
21 |
22 | class SubmittedCode(Base):
23 | __tablename__ = 'submitted_codes'
24 | __table_args__ = (
25 | {
26 | 'mysql_engine': 'InnoDB',
27 | 'mysql_charset': 'utf8',
28 | })
29 |
30 | id = Column(Integer, primary_key=True)
31 | evaluation = Column(Integer, ForeignKey(Evaluation.id, ondelete='CASCADE'),
32 | nullable=False)
33 | code = Column(Text, nullable=False)
34 |
--------------------------------------------------------------------------------
/model/task.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import (Column, Integer, String, DateTime, Text, ForeignKey,
4 | Boolean, Enum)
5 | from sqlalchemy.orm import relationship
6 |
7 | from . import Base
8 | from .user import User
9 | from .thread import Thread
10 | from .wave import Wave
11 |
12 |
13 | class Task(Base):
14 | __tablename__ = 'tasks'
15 | __table_args__ = {
16 | 'mysql_engine': 'InnoDB',
17 | 'mysql_charset': 'utf8mb4'
18 | }
19 |
20 | id = Column(Integer, primary_key=True)
21 | title = Column(String(255), nullable=False)
22 | author = Column(Integer, ForeignKey(User.id), nullable=True)
23 | co_author = Column(Integer, ForeignKey(User.id), nullable=True)
24 | wave = Column(Integer, ForeignKey(Wave.id), nullable=False)
25 |
26 | wave_ = relationship('Wave')
27 |
28 | prerequisite = Column(Integer, ForeignKey('prerequisities.id',
29 | ondelete='SET NULL'),
30 | nullable=True)
31 | intro = Column(String(500), nullable=False, default="")
32 | body = Column(Text, nullable=False, default="")
33 | solution = Column(Text, nullable=True)
34 | thread = Column(Integer, ForeignKey(Thread.id), nullable=False)
35 | picture_base = Column(String(255), nullable=True)
36 | time_created = Column(DateTime, default=datetime.datetime.utcnow)
37 | time_deadline = Column(DateTime, default=datetime.datetime.utcnow)
38 |
39 | prerequisite_obj = relationship(
40 | 'Prerequisite',
41 | primaryjoin='Task.prerequisite==Prerequisite.id',
42 | uselist=False
43 | )
44 | modules = relationship('Module', primaryjoin='Task.id==Module.task',
45 | order_by='Module.order')
46 | evaluation_public = Column(Boolean, nullable=False, default=False)
47 |
48 | git_path = Column(String(255), nullable=True)
49 | git_branch = Column(String(255), nullable=True)
50 | git_commit = Column(String(255), nullable=True)
51 | git_pull_id = Column(Integer, nullable=True)
52 | deploy_date = Column(DateTime, nullable=True, default=None)
53 | deploy_status = Column(
54 | Enum('default', 'deploying', 'done', 'error', 'diff'),
55 | nullable=False,
56 | default='default'
57 | )
58 | eval_comment = Column(Text, nullable=True, default='')
59 |
60 |
61 | class SolutionComment(Base):
62 | __tablename__ = 'solution_comments'
63 | __table_args__ = {
64 | 'mysql_engine': 'InnoDB',
65 | 'mysql_charset': 'utf8'
66 | }
67 |
68 | thread = Column(Integer, ForeignKey('threads.id'), nullable=False,
69 | primary_key=True)
70 | user = Column(Integer, ForeignKey('users.id'), nullable=False,
71 | primary_key=True)
72 | task = Column(Integer, ForeignKey('tasks.id'), nullable=False,
73 | primary_key=True)
74 |
--------------------------------------------------------------------------------
/model/text.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, String, Text, ForeignKey, text, Boolean
2 | from sqlalchemy.types import TIMESTAMP
3 | import datetime
4 |
5 | from . import Base
6 | from .module import Module
7 |
8 |
9 | class Text(Base):
10 | __tablename__ = 'text'
11 | __table_args__ = {
12 | 'mysql_engine': 'InnoDB',
13 | 'mysql_charset': 'utf8mb4',
14 | }
15 |
16 | id = Column(Integer, primary_key=True)
17 | module = Column(Integer, ForeignKey(Module.id), nullable=False)
18 | inputs = Column(Integer)
19 | diff = Column(Text)
20 | ignore_case = Column(Boolean)
21 | eval_script = Column(String(255))
22 |
--------------------------------------------------------------------------------
/model/thread.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, text
3 | from sqlalchemy.orm import relationship
4 |
5 | from sqlalchemy.types import TIMESTAMP
6 |
7 | from . import Base
8 | from .year import Year
9 | from .user import User
10 |
11 |
12 | class Thread(Base):
13 | __tablename__ = 'threads'
14 | __table_args__ = {
15 | 'mysql_engine': 'InnoDB',
16 | 'mysql_charset': 'utf8mb4'
17 | }
18 |
19 | id = Column(Integer, primary_key=True)
20 | title = Column(String(1000))
21 | public = Column(Boolean, nullable=False, default=True,
22 | server_default=text('TRUE'))
23 | year = Column(Integer, ForeignKey(Year.id), nullable=False)
24 |
25 | posts = relationship('Post', backref="Thread",
26 | primaryjoin="Post.thread==Thread.id")
27 |
28 |
29 | class ThreadVisit(Base):
30 | __tablename__ = 'threads_visits'
31 | __table_args__ = {
32 | 'mysql_engine': 'InnoDB',
33 | 'mysql_charset': 'utf8'
34 | }
35 |
36 | thread = Column(Integer, ForeignKey(Thread.id, ondelete='CASCADE'),
37 | primary_key=True, nullable=False)
38 | user = Column(Integer, ForeignKey(User.id, ondelete='CASCADE'),
39 | primary_key=True, nullable=False)
40 | last_visit = Column(TIMESTAMP, nullable=False,
41 | default=datetime.datetime.utcnow,
42 | server_default=text('CURRENT_TIMESTAMP'))
43 | last_last_visit = Column(TIMESTAMP, nullable=True)
44 |
--------------------------------------------------------------------------------
/model/token.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Interval
4 | from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method
5 |
6 | from . import Base
7 | from .user import User
8 |
9 |
10 | class Token(Base):
11 | __tablename__ = 'oauth2_tokens'
12 | __table_args__ = {
13 | 'mysql_engine': 'InnoDB',
14 | 'mysql_charset': 'utf8mb4'
15 | }
16 |
17 | access_token = Column(String(150), primary_key=True)
18 | user = Column(Integer, ForeignKey(User.id))
19 | expire = Column(DateTime, default=datetime.timedelta(hours=1))
20 | refresh_token = Column(String(150))
21 | granted = Column(DateTime, default=datetime.datetime.utcnow)
22 |
--------------------------------------------------------------------------------
/model/user.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, Integer, String, Boolean, Enum, Text, text
4 | from sqlalchemy.types import TIMESTAMP
5 | from sqlalchemy.orm import relationship
6 |
7 | from . import Base
8 |
9 |
10 | class User(Base):
11 | __tablename__ = 'users'
12 | __table_args__ = {
13 | 'mysql_engine': 'InnoDB',
14 | 'mysql_charset': 'utf8mb4'
15 | }
16 |
17 | id = Column(Integer, primary_key=True)
18 | email = Column(String(50), nullable=False, unique=True)
19 | github = Column(String(50), nullable=True)
20 | discord = Column(String(50), nullable=True)
21 | phone = Column(String(15))
22 | first_name = Column(String(50), nullable=False)
23 | nick_name = Column(String(50))
24 | last_name = Column(String(50), nullable=False)
25 | sex = Column(Enum('male', 'female', 'other'), nullable=False)
26 | password = Column(String(255), nullable=False)
27 | short_info = Column(Text, nullable=False)
28 | profile_picture = Column(String(255))
29 | role = Column(
30 | Enum('admin', 'org', 'participant', 'participant_hidden', 'tester'),
31 | nullable=False, default='participant', server_default='participant')
32 | enabled = Column(Boolean, nullable=False, default=True, server_default='1')
33 | registered = Column(TIMESTAMP, nullable=False,
34 | default=datetime.datetime.utcnow,
35 | server_default=text('CURRENT_TIMESTAMP'))
36 | last_logged_in = Column(TIMESTAMP, nullable=True)
37 |
38 | tasks = relationship("Task", primaryjoin='User.id == Task.author')
39 |
--------------------------------------------------------------------------------
/model/user_achievement.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, ForeignKey
2 |
3 | from . import Base
4 | from .user import User
5 | from .achievement import Achievement
6 | from .task import Task
7 |
8 |
9 | class UserAchievement(Base):
10 | __tablename__ = 'user_achievement'
11 | __table_args__ = {
12 | 'mysql_engine': 'InnoDB',
13 | 'mysql_charset': 'utf8mb4'
14 | }
15 |
16 | user_id = Column(Integer, ForeignKey(User.id, ondelete='CASCADE'),
17 | primary_key=True, nullable=False)
18 | achievement_id = Column(Integer,
19 | ForeignKey(Achievement.id, ondelete='CASCADE'),
20 | primary_key=True, nullable=False)
21 | task_id = Column(Integer, ForeignKey(Task.id, ondelete='CASCADE'),
22 | nullable=True)
23 |
--------------------------------------------------------------------------------
/model/user_notify.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
4 |
5 | from . import Base
6 | from .user import User
7 |
8 |
9 | class UserNotify(Base):
10 | __tablename__ = 'users_notify'
11 | __table_args__ = {
12 | 'mysql_engine': 'InnoDB',
13 | 'mysql_charset': 'utf8mb4'
14 | }
15 |
16 | user = Column(Integer, ForeignKey(User.id, ondelete='CASCADE'),
17 | primary_key=True)
18 | auth_token = Column(String(255), nullable=False)
19 | notify_eval = Column(Boolean, nullable=False, default=True)
20 | notify_response = Column(Boolean, nullable=False, default=True)
21 | notify_ksi = Column(Boolean, nullable=False, default=True)
22 | notify_events = Column(Boolean, nullable=False, default=True)
23 |
--------------------------------------------------------------------------------
/model/wave.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
4 | from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method
5 |
6 | from . import Base
7 | from .year import Year
8 | from .user import User
9 |
10 |
11 | class Wave(Base):
12 | __tablename__ = 'waves'
13 | __table_args__ = {
14 | 'mysql_engine': 'InnoDB',
15 | 'mysql_charset': 'utf8mb4'
16 | }
17 |
18 | id = Column(Integer, primary_key=True, nullable=False)
19 | year = Column(Integer, ForeignKey(Year.id), nullable=False)
20 | index = Column(Integer, nullable=False)
21 | caption = Column(String(100), nullable=True)
22 | garant = Column(Integer, ForeignKey(User.id), nullable=False)
23 | time_published = Column(DateTime, default=datetime.datetime.utcnow,
24 | nullable=False)
25 |
26 | @hybrid_property
27 | def public(self):
28 | return self.time_published <= datetime.datetime.utcnow()
29 |
--------------------------------------------------------------------------------
/model/year.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, Integer, String, Boolean, DECIMAL
4 |
5 | from . import Base
6 |
7 |
8 | class Year(Base):
9 | __tablename__ = 'years'
10 | __table_args__ = {
11 | 'mysql_engine': 'InnoDB',
12 | 'mysql_charset': 'utf8mb4'
13 | }
14 |
15 | id = Column(Integer, primary_key=True, nullable=False)
16 | year = Column(String(100), nullable=True)
17 | sealed = Column(Boolean, nullable=False, default=False)
18 | point_pad = Column(DECIMAL(precision=10, scale=1, asdecimal=False),
19 | nullable=False, default=0)
20 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | bcrypt==4.2.0
2 | cffi==1.17.0
3 | falcon==3.1.3
4 | gitdb==4.0.11
5 | gitdb2==4.0.2
6 | GitPython==3.1.43
7 | gunicorn==23.0.0
8 | humanfriendly==10.0
9 | lockfile==0.12.2
10 | multipart==0.2.5
11 | olefile==0.47
12 | pillow==10.4.0
13 | pycparser==2.22
14 | pypandoc==1.13
15 | pyparsing==2.4.7
16 | python-magic==0.4.27
17 | python-mimeparse==2.0.0
18 | pytz==2024.1
19 | requests==2.32.3
20 | six==1.16.0
21 | smmap==5.0.1
22 | smmap2==3.0.1
23 | SQLAlchemy==2.0.32
24 | python-dateutil==2.9.0
25 | PyMySQL==1.1.1
26 | ssage==1.4.0
27 |
--------------------------------------------------------------------------------
/runner:
--------------------------------------------------------------------------------
1 | #! /usr/bin/python3
2 |
3 | import os
4 | import re
5 | import sys
6 | import glob
7 | import subprocess
8 | import logging
9 |
10 | from optparse import OptionParser
11 |
12 | re_ignore = re.compile(r'(~$|^_|\.(dpkg-(old|dist|new|tmp)|example)$|\.pyc|\.comc$)')
13 |
14 | def main():
15 | parser = OptionParser("usage: %prog [options] start|stop|reload [configs]")
16 |
17 | parser.add_option('--pid-dir', dest='pid_dir',
18 | default='/var/run/gunicorn')
19 | parser.add_option('--log-dir', dest='log_dir',
20 | default='/var/log/gunicorn')
21 | parser.add_option('--server-name', dest='server_name',
22 | default='ksi-backend')
23 |
24 | parser.add_option('-v', '--verbosity', type='int', dest='verbosity',
25 | default=2, help='Verbosity level; 0=minimal output, 1=normal output' \
26 | ' 2=verbose output, 3=very verbose output')
27 |
28 | options, args = parser.parse_args()
29 |
30 | try:
31 | logging.basicConfig(
32 | format='%(levelname).1s: %(message)s',
33 | level={
34 | 0: logging.ERROR,
35 | 1: logging.WARNING,
36 | 2: logging.INFO,
37 | 3: logging.DEBUG,
38 | }[options.verbosity]
39 | )
40 | except KeyError:
41 | parser.error("Invalid verbosity level")
42 |
43 | log = logging.getLogger('gunicorn-debian')
44 |
45 | configs = args[1:]
46 |
47 | try:
48 | action = args[0]
49 | except IndexError:
50 | parser.error("Missing action")
51 |
52 | if action not in ('start', 'stop', 'restart', 'reload'):
53 | parser.error("Invalid action: %s" % action)
54 |
55 | if not os.path.exists(options.pid_dir):
56 | log.info("Creating %s", options.pid_dir)
57 | os.makedirs(options.pid_dir)
58 |
59 | sys.dont_write_bytecode = True
60 |
61 | CONFIG = {
62 | 'working_dir': '.',
63 | 'python': './ksi-py3-venv/bin/python3',
64 | 'user': 'ksi',
65 | 'group': 'ksi',
66 | 'environment': {
67 | 'PATH': os.environ[ "PATH" ],
68 | },
69 | 'args': [
70 | '-c',
71 | 'gunicorn_cfg.py',
72 | 'app:api',
73 | ]
74 | }
75 |
76 | config = Config(options.server_name, options, CONFIG, log)
77 |
78 | log.debug("Calling .%s() on %s", action, config.basename())
79 | getattr(config, action)()
80 |
81 | return 0
82 |
83 | class Config(dict):
84 | def __init__(self, filename, options, data, log):
85 | self.filename = filename
86 | self.options = options
87 | self.log = log
88 |
89 | data['args'] = list(data.get('args', []))
90 | data.setdefault('user', 'ksi')
91 | data.setdefault('group', 'ksi')
92 | data.setdefault('retry', '60')
93 | data.setdefault('environment', {})
94 | data.setdefault('working_dir', '.')
95 | data.setdefault('python', '/usr/bin/python3')
96 |
97 | self.update(data)
98 |
99 | def print_name(self):
100 | sys.stdout.write(" [%s]" % self.basename())
101 | sys.stdout.flush()
102 |
103 | def basename(self):
104 | return os.path.basename(self.filename)
105 |
106 | def pidfile(self):
107 | return os.path.join(self.options.pid_dir, '%s.pid' % self.basename())
108 |
109 | def logfile(self):
110 | return os.path.join(self.options.log_dir, '%s.log' % self.basename())
111 |
112 | def check_call(self, *args, **kwargs):
113 | self.log.debug("Calling subprocess.check_call(*%r, **%r)", args, kwargs)
114 | subprocess.check_call(*args, **kwargs)
115 |
116 | def start(self):
117 | daemon = './ksi-py3-venv/bin/gunicorn'
118 |
119 | args = [
120 | 'start-stop-daemon',
121 | '--start',
122 | '--oknodo',
123 | '--quiet',
124 | '--chdir', self['working_dir'],
125 | '--pidfile', self.pidfile(),
126 | '--exec', self['python'], '--', daemon,
127 | ]
128 |
129 | gunicorn_args = [
130 | '--pid', self.pidfile(),
131 | '--name', self.basename(),
132 | '--user', self['user'],
133 | '--group', self['group'],
134 | '--daemon',
135 | '--log-file', self.logfile(),
136 | ]
137 |
138 | env = os.environ.copy()
139 | env.update(self['environment'])
140 |
141 | self.check_call(args + gunicorn_args + self['args'], env=env)
142 |
143 | def stop(self):
144 | self.check_call((
145 | 'start-stop-daemon',
146 | '--stop',
147 | '--oknodo',
148 | '--quiet',
149 | '--retry', self['retry'],
150 | '--pidfile', self.pidfile(),
151 | ))
152 |
153 | def restart(self):
154 | self.stop()
155 | self.start()
156 |
157 | def reload(self):
158 | try:
159 | self.check_call((
160 | 'start-stop-daemon',
161 | '--stop',
162 | '--signal', 'HUP',
163 | '--quiet',
164 | '--pidfile', self.pidfile(),
165 | ))
166 | except subprocess.CalledProcessError:
167 | self.log.debug("Could not reload, so restarting instead")
168 | self.restart()
169 |
170 | if __name__ == '__main__':
171 | sys.exit(main())
172 |
--------------------------------------------------------------------------------
/util/__init__.py:
--------------------------------------------------------------------------------
1 | import cgi
2 |
3 | from .auth import UserInfo
4 | from .prerequisite import PrerequisitiesEvaluator
5 | from .task import TaskStatus
6 |
7 | from . import admin
8 |
9 | from . import module
10 | from . import task
11 | from . import prerequisite
12 | from . import quiz
13 | from . import sortable
14 | from . import programming
15 | from . import achievement
16 | from . import user
17 | from . import profile
18 | from . import thread
19 | from . import post
20 | from . import mail
21 | from . import config
22 | from . import text
23 | from . import correction
24 | from . import correctionInfo
25 | from . import wave
26 | from . import submissions
27 | from . import year
28 | from . import content
29 | from . import git
30 | from . import lock
31 | from . import feedback
32 | from . import user_notify
33 | from . import logger
34 |
35 |
36 | def decode_form_data(req):
37 | ctype, pdict = cgi.parse_header(req.content_type)
38 | return cgi.parse_multipart(req.stream, pdict)
39 |
--------------------------------------------------------------------------------
/util/achievement.py:
--------------------------------------------------------------------------------
1 | from db import session
2 | import model
3 | from util import config
4 |
5 |
6 | def to_json(achievement):
7 | return {
8 | 'id': achievement.id,
9 | 'title': achievement.title,
10 | 'active': True,
11 | 'picture': achievement.picture,
12 | 'description': achievement.description,
13 | 'persistent': (achievement.year is None),
14 | 'year': achievement.year
15 | }
16 |
17 |
18 | def ids_set(achievements):
19 | return set([achievement.id for achievement in achievements])
20 |
21 |
22 | def ids_list(achievements):
23 | return list(ids_set(achievements))
24 |
25 |
26 | def per_task(user_id, task_id):
27 | return session.query(model.Achievement).\
28 | join(model.UserAchievement,
29 | model.UserAchievement.achievement_id == model.Achievement.id).\
30 | filter(model.UserAchievement.user_id == user_id,
31 | model.UserAchievement.task_id == task_id).all()
32 |
--------------------------------------------------------------------------------
/util/admin/__init__.py:
--------------------------------------------------------------------------------
1 | from . import taskDeploy
2 | from . import taskMerge
3 | from . import waveDiff
4 | from . import task
5 |
--------------------------------------------------------------------------------
/util/admin/task.py:
--------------------------------------------------------------------------------
1 | import shutil
2 | import json
3 | from datetime import datetime
4 | from typing import Optional, List, Tuple, TypedDict, Set
5 |
6 | import git
7 | import requests
8 |
9 | import model
10 | import util
11 | from db import session
12 | from util.task import max_points
13 |
14 | LOCKFILE = '/var/lock/ksi-task-new'
15 |
16 |
17 | def createGit(git_path: str, git_branch: str, author_id: int,
18 | title: str) -> (str, Optional[int]):
19 | """
20 | Vytvori novou ulohu v repozitari a vytvori pull request na GitHubu, pokud je nastaveny token.
21 | :param git_path: Celá cesta k nové úloze v repozitáři
22 | :param git_branch: Jméno nové větve
23 | :param author_id: ID autora úlohy
24 | :param title: Název úlohy
25 | :return: SHA commitu a ID pull requestu
26 | """
27 | repo = git.Repo(util.git.GIT_SEMINAR_PATH)
28 | repo.git.checkout("master")
29 | repo.remotes.origin.pull()
30 |
31 | # Vytvorime novou gitovskou vetev
32 | repo.git.checkout("HEAD", b=git_branch)
33 |
34 | # Zkopirujeme vzorovou ulohu do adresare s ulohou
35 | # Cilovy adresar nesmi existovat (to vyzaduje copytree)
36 | target_path = util.git.GIT_SEMINAR_PATH + git_path
37 | shutil.copytree(util.git.GIT_SEMINAR_PATH + util.git.TASK_MOOSTER_PATH,
38 | target_path)
39 |
40 | # Predzpracovani dat v repozitari
41 | with open(target_path+'/task.json', 'r') as f:
42 | data = json.loads(f.read())
43 | data['author'] = author_id
44 | with open(target_path+'/task.json', 'w') as f:
45 | f.write(json.dumps(data, indent=4, ensure_ascii=False))
46 |
47 | with open(target_path+'/assignment.md', 'w') as f:
48 | s = title + ".\n\n" + \
49 | "# " + title + "\n\n" + \
50 | "Název úlohy musí být uvozen `#`, nikoliv podtrhnut rovnítky.\n\n"
51 | f.write(s)
52 |
53 | # Commit
54 | repo.index.add([git_path])
55 | repo.index.commit("Nova uloha: "+title)
56 |
57 | # Push
58 | # Netusim, jak udelat push -u, tohle je trosku prasarna:
59 | g = git.Git(util.git.GIT_SEMINAR_PATH)
60 | g.execute(["git", "push", "-u", "origin", git_branch+':'+git_branch])
61 |
62 | # Pull request
63 | seminar_repo = util.config.seminar_repo()
64 | github_token = util.config.github_token()
65 | github_api_org_url = util.config.github_api_org_url()
66 |
67 | pull_id = None
68 |
69 | if None not in (seminar_repo, github_token, github_api_org_url):
70 | # PR su per-service, teda treba urobit POST request na GitHub API
71 | url_root = github_api_org_url + seminar_repo
72 |
73 | headers = {
74 | "Accept": "application/vnd.github+json",
75 | "Authorization": "token " + github_token
76 | }
77 |
78 | pull_id = int(requests.post(
79 | url_root + "/pulls",
80 | headers=headers,
81 | data=json.dumps({
82 | "title": "Nova uloha: " + title,
83 | "head": git_branch,
84 | "base": "master"
85 | })
86 | ).json()['number'])
87 |
88 | author: Optional[model.User] = session.query(model.User).\
89 | filter(model.User.id == int(author_id)).\
90 | first()
91 |
92 | if author.github:
93 | requests.post(
94 | url_root + f"/issues/{pull_id}/assignees",
95 | headers=headers,
96 | data=json.dumps({
97 | "assignees": [author.github]
98 | })
99 | )
100 |
101 | return repo.head.commit.hexsha, pull_id
102 |
103 |
104 | def fetch_testers(task: model.Task) -> Tuple[List[model.User], List[str]]:
105 | seminar_repo = util.config.seminar_repo()
106 | github_token = util.config.github_token()
107 | github_api_org_url = util.config.github_api_org_url()
108 | pull_id = task.git_pull_id
109 |
110 | if None in (seminar_repo, github_token, github_api_org_url, pull_id):
111 | return [], []
112 |
113 | url_root = github_api_org_url + seminar_repo
114 |
115 | headers = {
116 | "Accept": "application/vnd.github+json",
117 | "Authorization": "token " + github_token
118 | }
119 |
120 | response = requests.get(url_root + f"/pulls/{pull_id}", headers=headers)
121 | response.raise_for_status()
122 | pull_data = response.json()
123 | reviewer_usernames: Set[str] = {reviewer['login'] for reviewer in pull_data.get('requested_reviewers', [])}
124 |
125 | response = requests.get(url_root + f"/pulls/{pull_id}/reviews", headers=headers)
126 | response.raise_for_status()
127 | reviewer_usernames.update({review['user']['login'] for review in response.json()})
128 |
129 | users: List[model.User] = session.query(model.User).filter(model.User.github.in_(reviewer_usernames)).all()
130 | reviewers_unknown = [reviewer for reviewer in reviewer_usernames if reviewer not in {user.github for user in users}]
131 | return users, reviewers_unknown
132 |
133 | class AdminJson(TypedDict):
134 | id: int
135 | title: str
136 | wave: int
137 | author: Optional[int]
138 | co_author: Optional[int]
139 | testers: List[int]
140 | git_path: Optional[str]
141 | git_branch: Optional[str]
142 | git_commit: Optional[str]
143 | deploy_date: Optional[datetime]
144 | deploy_status: str
145 | max_score: float
146 | eval_comment: str
147 |
148 |
149 | def admin_to_json(task: model.Task, amax_points: Optional[float] = None, do_fetch_testers: bool = True)\
150 | -> AdminJson:
151 | if not amax_points:
152 | amax_points = max_points(task.id)
153 |
154 | testers = []
155 | additional_testers = []
156 |
157 | if do_fetch_testers:
158 | testers, additional_testers = fetch_testers(task)
159 |
160 | return {
161 | 'id': task.id,
162 | 'title': task.title,
163 | 'wave': task.wave,
164 | 'author': task.author,
165 | 'co_author': task.co_author,
166 | 'git_path': task.git_path,
167 | 'git_branch': task.git_branch,
168 | 'git_commit': task.git_commit,
169 | 'git_pull_id': task.git_pull_id,
170 | 'deploy_date':
171 | task.deploy_date.isoformat() if task.deploy_date else None,
172 | 'deploy_status': task.deploy_status,
173 | 'max_score': float(format(amax_points, '.1f')),
174 | 'eval_comment': task.eval_comment,
175 | 'testers': [t.id for t in testers],
176 | 'additional_testers': additional_testers
177 | }
178 |
--------------------------------------------------------------------------------
/util/admin/taskMerge.py:
--------------------------------------------------------------------------------
1 | LOCKFILE = '/var/lock/ksi-task-merge'
2 |
--------------------------------------------------------------------------------
/util/admin/waveDiff.py:
--------------------------------------------------------------------------------
1 | LOCKFILE = '/var/lock/ksi-wave-diff'
2 |
--------------------------------------------------------------------------------
/util/auth.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from db import session
4 | import model
5 |
6 |
7 | class UserInfo:
8 |
9 | def __init__(self, user=None, token=None):
10 | self.id = user.id if user else None
11 | self.role = user.role if user else None
12 | self.token = token
13 | self.user = user
14 |
15 | def is_logged_in(self):
16 | return self.id is not None
17 |
18 | def get_id(self):
19 | return self.id
20 |
21 | def is_admin(self):
22 | return self.role == 'admin'
23 |
24 | def is_org(self):
25 | return self.role == 'org' or self.role == 'admin'
26 |
27 | def is_tester(self):
28 | return self.role == 'tester'
29 |
30 |
31 | def update_tokens():
32 | try:
33 | # refresh token nechavame v databazi jeste den, aby se uzivatel mohl
34 | # znovu prihlasit automaticky (napriklad po uspani pocitace)
35 | tokens = session.query(model.Token).all()
36 | tokens = [
37 | token
38 | for token in tokens
39 | if (datetime.datetime.utcnow() >
40 | token.expire+datetime.timedelta(days=14))
41 | ]
42 | for token in tokens:
43 | session.delete(token)
44 | session.commit()
45 | except:
46 | session.rollback()
47 | raise
48 |
--------------------------------------------------------------------------------
/util/cache.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 | from typing import Optional
3 | from gzip import compress, decompress
4 | from pickle import dumps, loads
5 | from base64 import b64encode, b64decode
6 |
7 | from db import session
8 | from model.cache import Cache
9 |
10 |
11 | def get_key(user: Optional[int], role: Optional[str], year: Optional[int], subkey: str) -> str:
12 | """
13 | Get a key for a user or role
14 | :param user: user ID
15 | :param role: role name
16 | :param subkey: key name
17 | :param year: year
18 | :return: key name
19 | """
20 | year = int(year) if year is not None else None
21 | user = int(user) if user is not None else None
22 | return f"{user=}:{role=}:{year=}:{subkey=}"
23 |
24 |
25 | def get_record(key: str) -> Optional[any]:
26 | """
27 | Get a record from cache
28 | :param key: key to get
29 | :return: data
30 | """
31 | data = session.query(Cache).filter(Cache.key == key).first()
32 | if data is None:
33 | return None
34 | if datetime.now() > data.expires:
35 | invalidate_cache(key)
36 | return None
37 | return loads(decompress(b64decode(data.value.encode('ascii'))))
38 |
39 |
40 | def invalidate_cache(key: str) -> None:
41 | """
42 | Invalidate cache
43 | :param key: key to invalidate
44 | """
45 | session.query(Cache).filter(Cache.key == key).delete()
46 | session.commit()
47 |
48 |
49 | def save_cache(key: str, data: any, expires_second: int) -> None:
50 | """
51 | Save data to cache
52 | :param expires_second: seconds until record is considered expired
53 | :param key: key to save
54 | :param data: data to save
55 | """
56 | data = b64encode(compress(dumps(data))).decode('ascii')
57 |
58 | if get_record(key) is not None:
59 | session.query(Cache).filter(Cache.key == key).delete()
60 | expires = datetime.now() + timedelta(seconds=expires_second)
61 | session.add(Cache(key=key, value=data, expires=expires))
62 | session.commit()
63 |
--------------------------------------------------------------------------------
/util/content.py:
--------------------------------------------------------------------------------
1 | import os
2 | from typing import List, TypedDict, Union
3 | from pathlib import Path
4 |
5 | from util.logger import audit_log
6 |
7 |
8 | class Content(TypedDict):
9 | id: str
10 | files: List[str]
11 | dirs: List[str]
12 |
13 |
14 | def empty_content(path: str) -> Content:
15 | return {'id': path, 'files': [], 'dirs': []}
16 |
17 |
18 | def dir_to_json(path: Union[str, Path]) -> Content:
19 | path_base = Path('data', 'content').absolute()
20 | path_full = (path_base / path).absolute() if isinstance(path, str) or not path.is_absolute() else path
21 |
22 | if not path_full.is_relative_to(path_base):
23 | audit_log(
24 | scope="HACK",
25 | user_id=None,
26 | message=f"Attempt to access content outside box using dir_to_json",
27 | message_meta={
28 | 'path': path
29 | }
30 | )
31 | return empty_content(path)
32 |
33 | if os.path.isdir(path_full):
34 | return {
35 | 'id': str(path_full.relative_to(path_base)),
36 | 'files': [f for f in os.listdir(path_full)
37 | if (path_full / f).is_file()],
38 | 'dirs': [f for f in os.listdir(path_full)
39 | if (path_full / f).is_dir()]
40 | }
41 | else:
42 | return empty_content(path)
43 |
--------------------------------------------------------------------------------
/util/correctionInfo.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from typing import List, Optional, TypedDict
3 |
4 | from sqlalchemy import func, distinct, or_, and_, not_, desc
5 | from sqlalchemy.dialects import mysql
6 |
7 | from db import session
8 | import model
9 | import util
10 |
11 |
12 | class UserJson(TypedDict):
13 | id: int
14 | first_name: str
15 | last_name: str
16 | role: str
17 | profile_picture: Optional[str]
18 | gender: str
19 |
20 |
21 | def user_to_json(user: model.User) -> UserJson:
22 | return {
23 | 'id': user.id,
24 | 'first_name': user.first_name,
25 | 'last_name': user.last_name,
26 | 'role': user.role,
27 | 'profile_picture': util.user.get_profile_picture(user),
28 | 'gender': user.sex
29 | }
30 |
31 |
32 | def _task_corr_state(task: model.Task, evaluating: Optional[bool] = None,
33 | tasks_corrected: Optional[List[int]] = None) -> str:
34 | if task.evaluation_public:
35 | return "published"
36 | if tasks_corrected is None:
37 | tasks_corrected = util.correction.tasks_corrected()
38 | if task.id in tasks_corrected:
39 | return "corrected"
40 |
41 | if evaluating is None:
42 | evaluating = session.query(model.Evaluation).\
43 | filter(model.Evaluation.evaluator != None).\
44 | join(model.Module, model.Module.id == model.Evaluation.module).\
45 | filter(model.Module.task == task.id).count() > 0
46 |
47 | return 'working' if evaluating else 'base'
48 |
49 |
50 | class TaskJson(TypedDict):
51 | id: int
52 | title: str
53 | wave: int
54 | author: int
55 | corr_state: str
56 | solvers: List[int]
57 |
58 |
59 | def task_to_json(task: model.Task,
60 | solvers: Optional[List[int]] = None,
61 | evaluating: Optional[bool] = None,
62 | tasks_corrected: Optional[List[int]] = None) -> TaskJson:
63 | if solvers is None:
64 | q = session.query(model.User.id).\
65 | join(model.Evaluation, model.Evaluation.user == model.User.id).\
66 | join(model.Module, model.Module.id == model.Evaluation.module).\
67 | filter(model.Module.task == task.id).group_by(model.User).all()
68 | solvers = [r for (r, ) in q]
69 |
70 | return {
71 | 'id': task.id,
72 | 'title': task.title,
73 | 'wave': task.wave,
74 | 'author': task.author,
75 | 'corr_state': _task_corr_state(task, evaluating, tasks_corrected),
76 | 'solvers': solvers
77 | }
78 |
--------------------------------------------------------------------------------
/util/feedback.py:
--------------------------------------------------------------------------------
1 | from db import session
2 | import model
3 | import util
4 | from collections import namedtuple
5 | import re
6 | import json
7 |
8 |
9 | FeedbackId = namedtuple('FeedbackId', ['user', 'task'])
10 |
11 | CATEGORIES = [
12 | {
13 | 'id': 'explained',
14 | 'ftype': 'stars',
15 | 'text': 'Jak dobře ti přišla úloha vysvětlená?',
16 | },
17 | {
18 | 'id': 'interesting',
19 | 'ftype': 'stars',
20 | 'text': 'Jak moc ti přijde úloha zajímavá?',
21 | },
22 | {
23 | 'id': 'difficult',
24 | 'ftype': 'line',
25 | 'text': 'Jak moc ti přijde úloha těžká?',
26 | },
27 | {
28 | 'id': 'comment',
29 | 'ftype': 'text_large',
30 | 'text': ('Chceš nám vzkázat něco, co nám pomůže příště úlohu připravit '
31 | 'lépe? (nepovinné)'),
32 | },
33 | ]
34 |
35 | MAX_CATEGORIES = 16
36 | MAX_ID_LEN = 32
37 | MAX_TYPE_LEN = 32
38 | MAX_QUESTION_LEN = 1024
39 | MAX_ANSWER_LEN = 8192
40 |
41 | ALLOWED_TYPES = ['stars', 'line', 'text_large']
42 | TYPE_TO_TYPE = {
43 | 'stars': int,
44 | 'line': int,
45 | 'text_large': str,
46 | }
47 | ALLOWED_RANGES = {
48 | 'stars': range(0, 6),
49 | 'line': range(0, 6),
50 | }
51 |
52 | class EForbiddenType(Exception):
53 | pass
54 |
55 |
56 | class EUnmatchingDataType(Exception):
57 | pass
58 |
59 |
60 | class EMissingAnswer(Exception):
61 | pass
62 |
63 |
64 | class EOutOfRange(Exception):
65 | pass
66 |
67 |
68 | def parse_feedback(categories):
69 | # Limit number of categories
70 | categoriess = categories[:MAX_CATEGORIES]
71 |
72 | # Check input for validity
73 | ids = set()
74 | to_store = []
75 | for category in categories:
76 | if category['id'][:MAX_ID_LEN] in ids:
77 | continue
78 | ids.add(category['id'][:MAX_ID_LEN])
79 |
80 | if 'answer' not in category:
81 | raise EMissingAnswer(
82 | "Missing answer for question '%s'" % (category['id'])
83 | )
84 |
85 | if isinstance(category['answer'], str):
86 | category['answer'] = category['answer'][:MAX_ANSWER_LEN]
87 |
88 | if category['ftype'] not in ALLOWED_TYPES:
89 | raise EUnmatchingDataType(
90 | "'%s' is not allowed as question type!" % (category['ftype'])
91 | )
92 |
93 | if not isinstance(category['answer'], TYPE_TO_TYPE[category['ftype']]):
94 | raise EForbiddenType(
95 | "'%s' is not allowed as answer of type '%s'!" % (
96 | type(category['answer']).__name__, category['ftype']
97 | )
98 | )
99 |
100 | if category['ftype'] in ALLOWED_RANGES and \
101 | category['answer'] not in ALLOWED_RANGES[category['ftype']]:
102 | raise EOutOfRange("'%s' out of range!" % (category['id']))
103 |
104 | to_store.append({
105 | 'id': category['id'][:MAX_ID_LEN],
106 | 'ftype': category['ftype'][:MAX_TYPE_LEN],
107 | 'text': category['text'][:MAX_QUESTION_LEN],
108 | 'answer': category['answer'],
109 | })
110 |
111 | return to_store
112 |
113 |
114 | def empty_to_json(task_id, user_id):
115 | return {
116 | 'id': task_id,
117 | 'userId': user_id,
118 | 'categories': CATEGORIES,
119 | 'filled': False,
120 | }
121 |
122 |
123 | def to_json(feedback):
124 | return {
125 | 'id': feedback.task,
126 | 'userId': feedback.user,
127 | 'lastUpdated': feedback.lastUpdated.isoformat(),
128 | 'categories': json.loads(feedback.content),
129 | 'filled': True,
130 | }
131 |
--------------------------------------------------------------------------------
/util/git.py:
--------------------------------------------------------------------------------
1 | GIT_SEMINAR_PATH = 'data/seminar/'
2 | TASK_MOOSTER_PATH = 'task-plain/'
3 |
--------------------------------------------------------------------------------
/util/lock.py:
--------------------------------------------------------------------------------
1 | from lockfile import LockFile
2 | import util
3 |
4 | GIT_LOCKS = [
5 | util.admin.taskDeploy.LOCKFILE,
6 | util.admin.waveDiff.LOCKFILE,
7 | util.admin.taskMerge.LOCKFILE,
8 | util.admin.task.LOCKFILE
9 | ]
10 |
11 |
12 | def git_locked():
13 | for lock in GIT_LOCKS:
14 | if LockFile(lock).is_locked():
15 | return lock
16 | return None
17 |
--------------------------------------------------------------------------------
/util/logger.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | from logging import Logger
4 | from typing import Optional
5 |
6 | from db import session
7 | from model.audit_log import AuditLog
8 |
9 |
10 | def get_log() -> Logger:
11 | """
12 | Gets the default Logger logging instance for the application
13 | """
14 | return logging.getLogger('gunicorn.error')
15 |
16 |
17 | def audit_log(scope: str, user_id: Optional[int], message: str, message_meta: Optional[dict] = None, year_id: Optional[int] = None) -> None:
18 | log_db = AuditLog(
19 | scope=scope,
20 | user_id=user_id,
21 | year_id=year_id,
22 | line=message,
23 | line_meta=json.dumps(message_meta) if message_meta else None
24 | )
25 | get_log().warning(f"[AUDIT] [{scope}] [{user_id}]: {message}")
26 | session.add(log_db)
27 | session.commit()
28 |
--------------------------------------------------------------------------------
/util/post.py:
--------------------------------------------------------------------------------
1 | from db import session
2 | import model
3 | import util
4 | import os
5 |
6 |
7 | def to_json(post, user_id, last_visit=None,
8 | last_visit_filled=False, reactions=None):
9 | if user_id:
10 | if not last_visit_filled:
11 | last_visit = session.query(model.ThreadVisit).\
12 | get((post.thread, user_id))
13 |
14 | is_new = True \
15 | if (last_visit is None) or (last_visit.last_last_visit is None)\
16 | else last_visit.last_last_visit < post.published_at
17 | else:
18 | is_new = False
19 |
20 | if reactions is None:
21 | reactions = post.reactions
22 |
23 | return {
24 | 'id': post.id,
25 | 'thread': post.thread,
26 | 'author': post.author,
27 | 'body': post.body,
28 | 'published_at': post.published_at.isoformat(),
29 | 'reaction': [reaction.id for reaction in reactions],
30 | 'is_new': is_new
31 | }
32 |
33 |
34 | def to_html(post, author=None):
35 | if not author:
36 | author = session.query(model.User).get(post.author)
37 | return "%s:
%s"\
38 | % (os.path.join(util.config.ksi_web(), "profil", str(author.id)),
39 | author.first_name + " " + author.last_name,
40 | post.body)
41 |
--------------------------------------------------------------------------------
/util/prerequisite.py:
--------------------------------------------------------------------------------
1 | from model import PrerequisiteType
2 |
3 | """
4 | Prerekvizity uloh maji omezeni:
5 | * OR nemuze byt vnitrni po AND
6 | -> (13 && 12) || (15 && 16) validni
7 | -> (13 || 12) || (15 && 16) validni
8 | -> (13 || 12) && (15 && 16) NEvalidni
9 | """
10 |
11 |
12 | class orList(list):
13 | def __init__(self, *args, **kwargs):
14 | super(orList, self).__init__(args[0])
15 |
16 |
17 | class andList(list):
18 | def __init__(self, *args, **kwargs):
19 | super(andList, self).__init__(args[0])
20 |
21 |
22 | def to_json(prereq):
23 | if prereq.type == PrerequisiteType.ATOMIC:
24 | return [[prereq.task]]
25 |
26 | if prereq.type == PrerequisiteType.AND:
27 | return [_to_json2(prereq)]
28 |
29 | if prereq.type == PrerequisiteType.OR:
30 | return _to_json2(prereq)
31 |
32 |
33 | def _to_json2(prereq):
34 | if prereq.type == PrerequisiteType.ATOMIC:
35 | return [prereq.task]
36 |
37 | elif prereq.type == PrerequisiteType.AND:
38 | l = []
39 | for child in prereq.children:
40 | l.extend(_to_json2(child))
41 | return l
42 |
43 | elif prereq.type == PrerequisiteType.OR:
44 | # Propagujeme ORy nahoru
45 | l = []
46 | for child in prereq.children:
47 | l_in = _to_json2(child)
48 | if isinstance(l_in[0], list):
49 | l.extend(l_in)
50 | else:
51 | l.append(l_in)
52 | return l
53 |
54 | else:
55 | return []
56 |
57 |
58 | class PrerequisitiesEvaluator:
59 |
60 | def __init__(self, root_prerequisite, fully_submitted):
61 | self.root_prerequisite = root_prerequisite
62 | self.fully_submitted = fully_submitted
63 |
64 | def evaluate(self):
65 | expr = self._parse_expression(self.root_prerequisite)
66 | return self._evaluation_step(expr)
67 |
68 | def _parse_expression(self, prereq):
69 | if prereq is None:
70 | return None
71 |
72 | if prereq.type == PrerequisiteType.ATOMIC:
73 | return prereq.task
74 |
75 | if prereq.type == PrerequisiteType.AND:
76 | return andList([self._parse_expression(child)
77 | for child in prereq.children])
78 |
79 | if prereq.type == PrerequisiteType.OR:
80 | return orList([self._parse_expression(child)
81 | for child in prereq.children])
82 |
83 | def _evaluation_step(self, expr):
84 | if expr is None:
85 | return True
86 |
87 | if isinstance(expr, andList):
88 | val = True
89 | for item in expr:
90 | val = val and self._evaluation_step(item)
91 | return val
92 |
93 | if isinstance(expr, orList):
94 | val = False
95 | for item in expr:
96 | val = val or self._evaluation_step(item)
97 | return val
98 |
99 | return expr in self.fully_submitted
100 |
--------------------------------------------------------------------------------
/util/profile.py:
--------------------------------------------------------------------------------
1 | from math import floor
2 |
3 | from db import session
4 | import model
5 | import util
6 |
7 |
8 | def fake_profile():
9 | return {'profile': {'id': 0, 'signed_in': False}}
10 |
11 |
12 | def to_json(user, profile, notify, year_obj, basic=False, sensitive: bool = False):
13 | """
14 | :param user:
15 | :param profile:
16 | :param notify:
17 | :param year_obj:
18 | :param basic:
19 | :param sensitive: it True, include sensitive information like user's address
20 | :return:
21 | """
22 | if basic:
23 | return {'profile': _basic_profile_to_json(user)}
24 | else:
25 | task_scores = {task: (points, wave, prereq) for task, points, wave,
26 | prereq in util.task.any_submitted(user.id, year_obj.id)}
27 |
28 | adeadline = util.task.after_deadline()
29 | fsubmitted = util.task.fully_submitted(user.id, year_obj.id)
30 | corrected = util.task.corrected(user.id)
31 | autocorrected_full = util.task.autocorrected_full(user.id)
32 | task_max_points_dict = util.task.max_points_dict()
33 |
34 | # task_achievements je seznam [(Task,Achievement)] pro vsechny
35 | # achievementy uzivatele user v uloze Task
36 | task_achievements = session.query(model.Task, model.Achievement).\
37 | join(model.UserAchievement,
38 | model.UserAchievement.task_id == model.Task.id).\
39 | join(model.Achievement,
40 | model.UserAchievement.achievement_id ==
41 | model.Achievement.id).\
42 | filter(model.UserAchievement.user_id == user.id,
43 | model.Achievement.year == year_obj.id).\
44 | group_by(model.Task, model.Achievement).\
45 | all()
46 |
47 | return {
48 | 'profile': dict(
49 | list(_basic_profile_to_json(user).items()) +
50 | list(_full_profile_to_json(user, profile, notify, task_scores,
51 | year_obj, sensitive=sensitive).items())
52 | ),
53 | 'tasks': [
54 | util.task.to_json(
55 | task, prereq, user, adeadline, fsubmitted, wave,
56 | task.id in corrected, task.id in autocorrected_full,
57 | task_max_points=task_max_points_dict[task.id])
58 | for task, (points, wave, prereq) in list(task_scores.items())
59 | ],
60 | 'taskScores': [
61 | task_score_to_json(
62 | task, points, user,
63 | [ach.id for (_, ach) in
64 | [tsk_ach for tsk_ach in task_achievements
65 | if tsk_ach[0].id == task.id]]
66 | )
67 | for task, (points, _, _) in list(task_scores.items())
68 | ]
69 | }
70 |
71 |
72 | def _basic_profile_to_json(user: model.User) -> dict:
73 | return {
74 | 'id': user.id,
75 | 'signed_in': True,
76 | 'first_name': user.first_name,
77 | 'last_name': user.last_name,
78 | 'nick_name': user.nick_name,
79 | 'profile_picture': util.user.get_profile_picture(user),
80 | 'short_info': user.short_info,
81 | 'email': user.email,
82 | 'gender': user.sex,
83 | 'role': user.role,
84 | 'github': user.github
85 | }
86 |
87 |
88 | def _full_profile_to_json(user, profile, notify, task_scores, year_obj, sensitive: bool = False):
89 | """
90 |
91 | :param user:
92 | :param profile:
93 | :param notify:
94 | :param task_scores:
95 | :param year_obj:
96 | :param sensitive: it True, include sensitive information like user's address
97 | :return:
98 | """
99 | points, cheat = util.user.sum_points(user.id, year_obj.id)
100 | summary = max(util.task.sum_points(
101 | year_obj.id, bonus=False),
102 | year_obj.point_pad
103 | )
104 | successful = format(floor((float(points) / summary) *
105 | 1000) / 10, '.1f') if summary != 0 else 0
106 |
107 | data = {
108 | 'addr_country': profile.addr_country,
109 | 'school_name': profile.school_name,
110 | 'school_street': profile.school_street,
111 | 'school_city': profile.school_city,
112 | 'school_zip': profile.school_zip,
113 | 'school_country': profile.school_country,
114 | 'school_finish': profile.school_finish,
115 | 'tshirt_size': profile.tshirt_size,
116 | 'achievements': list(util.achievement.ids_set(
117 | util.user.achievements(user.id, year_obj.id)
118 | )),
119 | 'percentile': util.user.percentile(user.id, year_obj.id),
120 | 'score': float(format(points, '.1f')),
121 | 'seasons': [key for (key,) in util.user.active_years(user.id)],
122 | 'percent': successful,
123 | 'results': [task.id for task in list(task_scores.keys())],
124 | 'tasks_num': len(util.task.fully_submitted(user.id, year_obj.id)),
125 |
126 | 'notify_eval': notify.notify_eval if notify else True,
127 | 'notify_response': notify.notify_response if notify else True,
128 | 'notify_ksi': notify.notify_ksi if notify else True,
129 | 'notify_events': notify.notify_events if notify else True,
130 | 'cheat': cheat,
131 | 'discord': user.discord
132 | }
133 |
134 | if sensitive:
135 | data.update({
136 | 'addr_street': profile.addr_street,
137 | 'addr_city': profile.addr_city,
138 | 'addr_zip': profile.addr_zip,
139 | })
140 | return data
141 |
142 | # \achievements ocekava seznam ID achievementu nebo None
143 |
144 |
145 | def task_score_to_json(task, points, user, achievements=None):
146 | if achievements is None:
147 | achievements = [
148 | achievement.achievement_id
149 | for achievement in
150 | session.query(model.UserAchievement).
151 | filter(model.UserAchievement.user_id == user.id,
152 | model.UserAchievement.task_id == task.id).
153 | all()
154 | ]
155 |
156 | return {
157 | 'id': task.id,
158 | 'task': task.id,
159 | 'achievements': achievements,
160 | 'score': float(format(points, '.1f')) if task.evaluation_public
161 | else None
162 | }
163 |
--------------------------------------------------------------------------------
/util/quiz.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import json
4 |
5 | from db import session
6 | import model
7 | import json
8 |
9 | r"""
10 | Specifikace \data v databazi modulu pro "quiz":
11 | "quiz": [{
12 | "type": Enum('checkbox', 'radio')
13 | "question": Text
14 | "text": String
15 | "options": ["opt1", "opt2", ...]
16 | "correct": [seznam_indexu_spravnych_odpovedi]
17 | },
18 | {
19 | ...
20 | }]
21 | """
22 |
23 |
24 | def to_json(db_dict, user_id):
25 | return [_question_to_json(question) for question in db_dict['quiz']]
26 |
27 |
28 | def evaluate(task, module, data):
29 | report = '=== Evaluating quiz id \'%s\' for task id \'%s\' ===\n\n' % (
30 | module.id, task)
31 | report += ' Raw data: ' + json.dumps(data, ensure_ascii=False) + '\n'
32 | report += ' Evaluation:\n'
33 |
34 | overall_results = True
35 | questions = json.loads(module.data)['quiz']
36 | i = 0
37 |
38 | for question in questions:
39 | answers_user = [int(item) for item in data[i]]
40 | is_correct = (answers_user == question['correct'])
41 |
42 | report += ' [%s] Question %d -- user answers: %s, '\
43 | 'correct answers: %s\n' % (
44 | 'y' if is_correct else 'n',
45 | i,
46 | answers_user,
47 | question['correct'])
48 | overall_results &= is_correct
49 | i += 1
50 |
51 | report += '\n Overall result: [' + ('y' if overall_results else 'n') + ']'
52 |
53 | return (overall_results, report)
54 |
55 |
56 | def _question_to_json(question):
57 | return {
58 | 'type': question['type'],
59 | 'question': question['question'],
60 | 'text': question['text'] if 'text' in question else '',
61 | 'options': question['options']
62 | }
63 |
--------------------------------------------------------------------------------
/util/sortable.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from db import session
4 | import model
5 |
6 | r"""
7 | Specifikace \data v databazi modulu pro "sortable":
8 | "sortable": {
9 | "style": -- zatim nepodporovano, planovano do budoucna
10 |
11 | "fixed": [{
12 | "content": String,
13 | "offset": Integer,
14 | }, {...}, ...]
15 |
16 | "movable": [
17 | vypada uplne stejne, jako "fixed"
18 | ]
19 |
20 | "correct": [[pole popisujici spravne poradi:
21 | napr. "b1", "a1", "a2", "b2" rika:
22 | nejdriv je prvni movable, pak prvni fixed, pak druhy fixed,
23 | pak druhy movable], [druhe_mozne_reseni]]
24 | }
25 | """
26 |
27 |
28 | def to_json(db_dict, user_id):
29 | return {
30 | 'fixed': db_dict['sortable']['fixed'],
31 | 'movable': db_dict['sortable']['movable']
32 | }
33 |
34 |
35 | def evaluate(task, module, data):
36 | report = '=== Evaluating sortable id \'%s\' for task id \'%s\' ===\n\n' % (
37 | module.id, task)
38 | report += ' Raw data: ' + json.dumps(data, ensure_ascii=False) + '\n'
39 | report += ' Evaluation:\n'
40 |
41 | sortable = json.loads(module.data)['sortable']
42 | user_order = data
43 | result = (user_order in sortable['correct'])
44 |
45 | report += ' User order: %s\n' % user_order
46 | report += ' Correct order: %s\n' % sortable['correct']
47 | report += '\n Overall result: [%s]' % ('y' if result else 'n')
48 |
49 | return (result, report)
50 |
--------------------------------------------------------------------------------
/util/submissions.py:
--------------------------------------------------------------------------------
1 | import unicodedata
2 |
3 |
4 | def strip_accents(s):
5 | return ''.join(c for c in unicodedata.normalize('NFD', s)
6 | if unicodedata.category(c) != 'Mn')
7 |
--------------------------------------------------------------------------------
/util/text.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import tempfile
4 | import shutil
5 |
6 | from db import session
7 | import model
8 | import subprocess
9 |
10 | from util import UserInfo
11 |
12 | r"""
13 | Specifikace \data v databazi modulu pro "text":
14 | text = {
15 | inputs = 3
16 | questions = ["otazka1", "otazka2", ...]
17 | diff = ["spravne_reseni_a", "spravne_reseni_b", "spravne_reseni_c"]
18 | eval_script = "/path/to/eval/script.py"
19 | ignore_case = True
20 | }
21 | Kazdy modul muze mit jen jeden text (s vice inputy).
22 | """
23 |
24 | RESULT_FILE = 'eval.out'
25 |
26 |
27 | class ECheckError(Exception):
28 | pass
29 |
30 |
31 | def to_json(db_dict, user_id):
32 | if 'questions' not in db_dict['text']:
33 | # Stary format textoveho modulu (bez textu otazek) -> vytvorit texty
34 | # otazek.
35 | return {
36 | 'questions': [
37 | 'Otázka ' + str(i + 1)
38 | for i in range(db_dict['text']['inputs'])
39 | ]
40 | }
41 | else:
42 | # Novy format vcetne textu otazek -> vratime texty otazek.
43 | return {'questions': db_dict['text']['questions']}
44 |
45 |
46 | def eval_text(eval_script, data, reporter, user: UserInfo):
47 | """ Evaluate text module by a script. """
48 |
49 | path = tempfile.mkdtemp()
50 | try:
51 | stdout_path = os.path.join(path, 'stdout')
52 | stderr_path = os.path.join(path, 'stderr')
53 |
54 | cmd = [os.path.abspath(eval_script)] + data
55 |
56 | with open(stdout_path, 'w') as stdout,\
57 | open(stderr_path, 'w') as stderr:
58 | p = subprocess.Popen(
59 | cmd,
60 | stdout=stdout,
61 | stderr=stderr,
62 | cwd=path,
63 | env={
64 | 'KSI_USER': f"{user.id}"
65 | }
66 | )
67 | p.wait(timeout=10) # seconds
68 |
69 | res = {'result': 'ok' if p.returncode == 0 else 'nok'}
70 |
71 | reporter += 'Stdout:\n'
72 | with open(stdout_path, 'r') as f:
73 | reporter += f.read()
74 |
75 | if os.path.getsize(stderr_path) > 0:
76 | reporter += 'Eval script returned nonempty stderr:\n'
77 | with open(stderr_path, 'r') as f:
78 | reporter += f.read()
79 | raise ECheckError('Eval script returned non-empty stderr!')
80 |
81 | # Load results from optional file.
82 | result_path = os.path.join(path, RESULT_FILE)
83 | if os.path.isfile(result_path):
84 | with open(result_path, 'r') as r:
85 | data = json.loads(r.read())
86 |
87 | if 'message' in data:
88 | res['message'] = data['message']
89 |
90 | if 'score' in data and res['result'] == 'ok':
91 | res['score'] = round(data['score'], 1)
92 |
93 | return res
94 |
95 | finally:
96 | if os.path.isdir(path):
97 | shutil.rmtree(path)
98 |
99 |
100 | def evaluate(task, module, data, reporter, user: UserInfo):
101 | reporter += '=== Evaluating text id \'%s\' for task id \'%s\' ===\n\n' % (
102 | module.id, task)
103 | reporter += 'Raw data: ' + json.dumps(data, ensure_ascii=False) + '\n'
104 | reporter += 'Evaluation:\n'
105 |
106 | text = json.loads(module.data)['text']
107 |
108 | if 'diff' in text:
109 | orig = text['diff']
110 | result = True
111 | reporter += 'Diff used!\n'
112 | for o, item in zip(orig, data):
113 | s1 = o.rstrip().lstrip().encode('utf-8')
114 | s2 = item.rstrip().lstrip().encode('utf-8')
115 | if ('ignore_case' in text) and (text['ignore_case']):
116 | s1 = s1.lower()
117 | s2 = s2.lower()
118 | result = result and s1 == s2
119 |
120 | if len(data) != len(orig):
121 | result = False
122 |
123 | return {
124 | 'result': 'ok' if result else 'nok'
125 | }
126 |
127 | elif 'eval_script' in text:
128 | return eval_text(text['eval_script'], data, reporter, user)
129 |
130 | else:
131 | reporter += 'No eval method specified!\n'
132 | return {
133 | 'result': 'error',
134 | 'message': 'Není dostupná žádná metoda opravení!'
135 | }
136 |
--------------------------------------------------------------------------------
/util/thread.py:
--------------------------------------------------------------------------------
1 | from db import session
2 | import model
3 |
4 |
5 | def to_json(thread, user_id=None, unread_cnt=None, posts_cnt=None):
6 | if posts_cnt is None:
7 | posts_cnt = session.query(model.Post).\
8 | filter(model.Post.thread == thread.id).count()
9 | if unread_cnt is None:
10 | if user_id is None:
11 | unread_cnt = posts_cnt
12 | else:
13 | unread_cnt = count_unread(user_id, thread.id)
14 |
15 | return {
16 | 'id': thread.id,
17 | 'title': thread.title,
18 | 'unread': unread_cnt,
19 | 'posts_count': posts_cnt,
20 | 'details': thread.id,
21 | 'year': thread.year
22 | }
23 |
24 |
25 | def details_to_json(thread, root_posts=None):
26 | if root_posts is None:
27 | root_posts = [
28 | post.id for post in session.query(model.Post).
29 | filter(model.Post.thread == thread.id,
30 | model.Post.parent == None)
31 | ]
32 |
33 | return {
34 | 'id': thread.id,
35 | 'root_posts': root_posts
36 | }
37 |
38 |
39 | def get_visit(user_id, thread_id):
40 | return session.query(model.ThreadVisit).get((thread_id, user_id))
41 |
42 |
43 | def get_user_visit(user_id, year_id):
44 | return session.query(model.ThreadVisit).\
45 | join(model.Thread, model.Thread.id == model.ThreadVisit.thread).\
46 | filter(model.ThreadVisit.user == user_id,
47 | model.Thread.year == year_id).\
48 | all()
49 |
50 |
51 | def count_unread(user_id, thread_id):
52 | if user_id is None:
53 | return 0
54 |
55 | visit = get_visit(user_id, thread_id)
56 |
57 | if not visit:
58 | return 0
59 |
60 | return session.query(model.Post).\
61 | filter(model.Post.thread == thread_id,
62 | model.Post.published_at > visit.last_visit).\
63 | count()
64 |
65 |
66 | def is_eval_thread(user_id, thread_id):
67 | return session.query(model.SolutionComment).\
68 | filter(model.SolutionComment.user == user_id,
69 | model.SolutionComment.thread == thread_id).\
70 | count() > 0
71 |
--------------------------------------------------------------------------------
/util/user_notify.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import random
3 | import string
4 | from typing import Optional
5 |
6 | from db import session
7 | import model
8 |
9 | TOKEN_LENGTH = 40
10 |
11 |
12 | def _generate_token() -> str:
13 | return ''.join([
14 | random.choice(string.ascii_letters + string.digits)
15 | for x in range(TOKEN_LENGTH)
16 | ])
17 |
18 |
19 | def new_token() -> str:
20 | return hashlib.sha256(_generate_token().encode('utf-8')).hexdigest()[:TOKEN_LENGTH]
21 |
22 |
23 | def get(user_id: int) -> model.UserNotify:
24 | return normalize(session.query(model.UserNotify).get(user_id), user_id)
25 |
26 |
27 | def normalize(notify: Optional[model.UserNotify],
28 | user_id: int) -> model.UserNotify:
29 | if notify is None:
30 | notify = model.UserNotify(
31 | user=user_id,
32 | auth_token=new_token(),
33 | notify_eval=True,
34 | notify_response=True,
35 | notify_ksi=True,
36 | notify_events=True,
37 | )
38 | return notify
39 |
--------------------------------------------------------------------------------
/util/wave.py:
--------------------------------------------------------------------------------
1 | from db import session
2 | import model
3 | import util
4 | from typing import TypedDict, Tuple, Optional
5 |
6 |
7 | class Wave(TypedDict):
8 | id: int
9 | year: int
10 | index: int
11 | caption: str
12 | garant: int
13 | time_published: str
14 | public: bool
15 | sum_points: float
16 | tasks_cnt: int
17 |
18 |
19 | def to_json(wave: model.Wave,
20 | sum_points: Optional[Tuple[float, int]] = None) -> Wave:
21 | if sum_points is None:
22 | sum_points = util.task.max_points_wave_dict()[wave.id]
23 |
24 | return {
25 | 'id': wave.id,
26 | 'year': wave.year,
27 | 'index': wave.index,
28 | 'caption': wave.caption,
29 | 'garant': wave.garant,
30 | 'time_published': wave.time_published.isoformat(),
31 | 'public': wave.public,
32 | 'sum_points': sum_points[0],
33 | 'tasks_cnt': sum_points[1]
34 | }
35 |
--------------------------------------------------------------------------------
/util/year.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, Tuple, TypedDict
2 |
3 | from db import session
4 | import model
5 | from util import config
6 | import util
7 |
8 |
9 | class Year(TypedDict):
10 | id: int
11 | index: int
12 | year: str
13 | sum_points: float
14 | tasks_cnt: int
15 | sealed: bool
16 | point_pad: float
17 |
18 |
19 | def to_json(year: model.Year,
20 | sum_points: Optional[Tuple[float, int]] = None) -> Year:
21 | if sum_points is None:
22 | sum_points = util.task.max_points_year_dict()[year.id]
23 |
24 | return {
25 | 'id': year.id,
26 | 'index': year.id,
27 | 'year': year.year,
28 | 'sum_points': sum_points[0],
29 | 'tasks_cnt': int(sum_points[1]),
30 | 'sealed': year.sealed,
31 | 'point_pad': year.point_pad
32 | }
33 |
34 |
35 | def year_end(year: model.Year) -> int:
36 | return int(year.year.replace(" ", "").split("/")[0]) + 1
37 |
--------------------------------------------------------------------------------
/utils/generate_org_performance_report.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env/python3
2 |
3 | """
4 | Parses seminar repository and asks if it should create all found tasks that are not currently found on backend.
5 | Optionally set following environment variables:
6 | - YEAR - the year/directory name inside seminar repository
7 | - BACKEND - backend URL including https
8 | - TOKEN - your login token (can be extracted from frontend)
9 | """
10 | import collections
11 | import json
12 | from typing import Dict, TypedDict, Optional, List, Set
13 | from urllib import request
14 |
15 | from util_login import KSILogin
16 |
17 |
18 | class Task(TypedDict):
19 | id: int
20 | title: str
21 | wave: int
22 | max_score: float
23 | author: int
24 | co_author: Optional[int]
25 | testers: List[int]
26 | additional_testers: List[str]
27 | git_pull_id: Optional[int]
28 |
29 |
30 | class Wave(TypedDict):
31 | id: int
32 | caption: str
33 | garant: int
34 |
35 |
36 | # noinspection PyDefaultArgument
37 | def fetch_user_name(backend: KSILogin, user_id: int, cache: Dict[int, str] = {}) -> str:
38 | if user_id not in cache:
39 | url, params = backend.construct_url(f'/users/{user_id}')
40 | with request.urlopen(request.Request(url, **params)) as res:
41 | user = json.loads(res.read().decode('utf8'))['user']
42 | cache[user_id] = f"{user['first_name'].strip()} {user['last_name'].strip()} ({user_id})"
43 | return cache[user_id]
44 |
45 |
46 | def fetch_known_waves(backend: KSILogin) -> Dict[int, Wave]:
47 | url, params = backend.construct_url('/waves')
48 | with request.urlopen(request.Request(url, **params)) as res:
49 | waves = json.loads(res.read().decode('utf8'))['waves']
50 | return {x['id']: x for x in waves}
51 |
52 |
53 |
54 | def fetch_known_tasks(backend: KSILogin) -> List[Task]:
55 | url, params = backend.construct_url('/admin/atasks?fetch_testers=1')
56 | with request.urlopen(request.Request(url, **params)) as res:
57 | waves = json.loads(res.read().decode('utf8'))['atasks']
58 | return waves
59 |
60 |
61 | def main() -> int:
62 | user_lines: Dict[str, List[str]] = collections.defaultdict(list)
63 | unknown_testers: Set[str] = set()
64 | unmatched_tasks: Set[str] = set()
65 |
66 | with KSILogin.login_auto() as login:
67 | waves = fetch_known_waves(login)
68 |
69 | print('Select the minimal wave number to be included in the report:')
70 | for wave_id, wave_data in waves.items():
71 | print(f"{wave_id}: {wave_data['caption']} (garant: {fetch_user_name(login, wave_data['garant'])})")
72 | min_wave = int(input('Enter the minimal wave number: '))
73 | assert min_wave in waves
74 |
75 | for wave_id, wave_data in waves.items():
76 | if wave_id < min_wave:
77 | continue
78 | user_lines[fetch_user_name(login, wave_data['garant'])].append(f"G{wave_id}")
79 |
80 | for task in fetch_known_tasks(login):
81 | task_author: str = fetch_user_name(login, task["author"])
82 | task_co_author: Optional[str] = fetch_user_name(login, task["co_author"]) if task["co_author"] else None
83 | testers: List[str] = [fetch_user_name(login, tester) for tester in task["testers"]] + task["additional_testers"]
84 | task_is_large: bool = task["max_score"] > 5.0
85 | wave: int = task["wave"]
86 |
87 | if wave < min_wave:
88 | continue
89 |
90 | unknown_testers.update(task["additional_testers"])
91 | if task["git_pull_id"] is None:
92 | unmatched_tasks.add(f"{task['title']} (wave {wave}, id {task['id']})")
93 | continue
94 |
95 | author_line = f"{'U' if task_is_large else 'u'}{wave}" + ('' if task_co_author is None else '_')
96 | tester_line = f"{'T' if task_is_large else 't'}{wave}"
97 |
98 | user_lines[task_author].append(author_line)
99 | if task_co_author:
100 | user_lines[task_co_author].append(author_line)
101 |
102 | for tester in testers:
103 | if tester == fetch_user_name(login, waves[wave]['garant']):
104 | continue
105 | user_lines[tester].append(tester_line)
106 |
107 | print('ORGANIZATION PERFORMANCE REPORT')
108 | for user, line_parts in user_lines.items():
109 | line_parts_with_count = []
110 | for distinct_part in set(line_parts):
111 | line_parts_with_count.append(f"{line_parts.count(distinct_part) if not distinct_part.startswith('G') else ''}{distinct_part}")
112 | print(f"{user} - {'; '.join(line_parts_with_count)}")
113 |
114 | if unknown_testers or unmatched_tasks:
115 | print('WARNING: SOME ENTRIES COULD NOT BE MATCHED')
116 | if unknown_testers:
117 | print("Unknown testers:\n- " + '\n- '.join(unknown_testers))
118 | if unmatched_tasks:
119 | print("Unmatched tasks:\n- " + '\n- '.join(unmatched_tasks))
120 |
121 | return 0
122 |
123 |
124 | if __name__ == '__main__':
125 | exit(main())
126 |
--------------------------------------------------------------------------------
/utils/upload-diplomas.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env/python3
2 |
3 | """
4 | Parses seminar repository and asks if it should create all found tasks that are not currently found on backend.
5 | Requires following environment variables:
6 | - DIPLOMAS - path to the directory with signed diploma PDFs
7 | - YEAR_ID - id of the year to get users from
8 |
9 | Optinal environment variables are:
10 | - BACKEND - backend URL including https
11 | - TOKEN - your login token (can be extracted from frontend)
12 | """
13 | import json
14 | from os import environ
15 | from pathlib import Path
16 | from typing import Dict, Set, Union
17 | from urllib import request
18 | from util_login import KSILogin
19 |
20 |
21 | def fetch_successful_user_diplomas(user_id: int, backend_url: str, token: str, year: Union[str, int]) -> Set[int]:
22 | """
23 | Fetches years for which user was given a diploma
24 | :param backend_url: url of the backend, including https
25 | :param token: login token
26 | :param year: year id
27 | :param user_id: id of the user
28 | :return: years for which user was given a diploma
29 | """
30 | with request.urlopen(request.Request(f"{backend_url}/diplomas/{user_id}", headers={'Authorization': token, 'year': year})) as res:
31 | diplomas = json.loads(res.read().decode('utf8'))['diplomas']
32 |
33 | return set(map(
34 | lambda x: x['year'],
35 | diplomas
36 | ))
37 |
38 |
39 | def fetch_successful_users(backend_url: str, token: str, year: Union[str, int]) -> Dict[str, int]:
40 | """
41 | Fetches a map of all SUCCESSFUL users in the year in format email: user_id
42 | :param backend_url: url of the backend, including https
43 | :param token: login token
44 | :param year: year id
45 | :return: users map in format email: user_id
46 | """
47 | with request.urlopen(request.Request(f"{backend_url}/users", headers={'Authorization': token, 'year': year})) as res:
48 | users = json.loads(res.read().decode('utf8'))['users']
49 |
50 | return {
51 | u['email']: u['id']
52 | for u in filter(
53 | lambda x: x.get('successful', False),
54 | users
55 | )
56 | }
57 |
58 |
59 | def upload_diploma(user_id: int, diploma: Path, backend_url: str, token: str, year: Union[str, int]) -> None:
60 | with diploma.open('rb') as f:
61 | diploma_content = f.read()
62 |
63 | data_boundary = b'ksi12345678ksi'
64 | data = b'--' + data_boundary + b'\n' + \
65 | b'Content-Disposition: form-data; name="file" filename="diploma.pdf"\n' + \
66 | b'Content-Type: application/pdf\n\n' + \
67 | diploma_content + b'\n\n' + \
68 | b'--' + data_boundary + b'--'
69 |
70 | req = request.Request(
71 | f"{backend_url}/admin/diploma/{user_id}/grant",
72 | headers={
73 | 'Authorization': token,
74 | 'year': year,
75 | 'Content-Type': 'multipart/form-data; charset=UTF-8; boundary=' + data_boundary.decode('ascii'),
76 | 'Content-Length': len(data)
77 | },
78 | method="POST",
79 | data=data
80 | )
81 |
82 | with request.urlopen(req) as res:
83 | res.read()
84 |
85 | def main() -> int:
86 | backend_url = environ.get('BACKEND', 'https://rest.ksi.fi.muni.cz')
87 | year_id = int(environ['YEAR_ID'])
88 |
89 | if 'TOKEN' not in environ:
90 | login = KSILogin(backend_url)
91 | if not login.login(environ['USER'], environ.get('PASSWORD')):
92 | print('ERROR: Login failed')
93 | return 1
94 | environ['TOKEN'] = login.token
95 |
96 | backend = (backend_url, environ['TOKEN'], year_id)
97 | dir_diplomas = Path(environ['DIPLOMAS'])
98 |
99 | if not dir_diplomas.is_dir():
100 | print(f'{dir_diplomas} is not valid directory')
101 |
102 | user_map = fetch_successful_users(*backend)
103 | print(f"Found {len(user_map)} successful users")
104 |
105 | pdf_diploma_extension = '.signed.pdf'
106 | files_diplomas = [x for x in dir_diplomas.iterdir() if x.name.lower().endswith(pdf_diploma_extension)]
107 | print(f"Found {len(files_diplomas)} diplomas")
108 |
109 | emails_left: Set[str] = set(user_map.keys())
110 | emails_extra: Set[str] = set()
111 | for file_diploma in files_diplomas:
112 | email = file_diploma.name.split(pdf_diploma_extension, 1)[0]
113 | if email not in user_map:
114 | emails_extra.add(email)
115 | continue
116 | emails_left.remove(email)
117 | if year_id in fetch_successful_user_diplomas(user_map[email], *backend):
118 | print(f'- {email} already has a diploma for this year')
119 | continue
120 | print(f'- uploading diploma for {email}')
121 | upload_diploma(user_map[email], file_diploma, *backend)
122 |
123 | if emails_left:
124 | print(f"Users without diploma: {emails_left}")
125 | else:
126 | print('All diplomas found')
127 | if emails_extra:
128 | print(f"Users that should not have diploma: {emails_extra}")
129 | else:
130 | print('No extra diplomas found')
131 | return 0
132 |
133 |
134 | if __name__ == '__main__':
135 | exit(main())
136 |
--------------------------------------------------------------------------------
/utils/use-named-requirements.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | """
4 | Replaces all ID-based prerequisities with their name-based alternatives by resolving their IDs againts the backend
5 |
6 | The first argument may be path to the task.json file or to a directory in which the task.json is supposed to be recursivelly searched
7 | """
8 |
9 | from urllib import request
10 | from pathlib import Path
11 | from typing import Set, NamedTuple, Dict
12 | from os import environ
13 | from sys import argv
14 | import json
15 | import re
16 |
17 | from util_login import KSILogin
18 |
19 |
20 | def fetch_task_path(task_id: int, backend_url: str, token: str) -> str:
21 | """
22 | Fetches git path for given task
23 | :param backend_url: url of the backend, including https
24 | :param token: login token
25 | :param task_id: ID of the task to fetch
26 | :return: git path of given task
27 | """
28 | with request.urlopen(request.Request(f"{backend_url}/admin/atasks/{task_id}", headers={'Authorization': token})) as res:
29 | result = json.loads(res.read().decode('utf8'))
30 | return result["atask"]["git_path"]
31 |
32 |
33 | def replace_requiements(file_task_meta: Path, backend_url: str, token: str) -> None:
34 | print(f"[*] processing {file_task_meta.absolute()}")
35 |
36 | with file_task_meta.open('r') as f:
37 | content = json.load(f)
38 |
39 | requirements = content["prerequisities"]
40 | print(f" [-] {requirements=}")
41 | if requirements is None:
42 | return
43 | # force string for int-based requiements
44 | requirements = str(requirements)
45 |
46 | def _replace(match: re.Match):
47 | prefix = match.group(1)
48 | suffix = match.group(3)
49 | task_id = int(match.group(2))
50 | print(f" [.] processing {task_id} ... ", end="", flush=True)
51 | task_path = fetch_task_path(task_id, backend_url, token).rsplit('/', 1)[-1]
52 | print(task_path)
53 | return f"{prefix}{task_path}{suffix}"
54 |
55 | requirements = re.sub(r"(^|\s)(\d+)($|\s)", _replace, requirements)
56 | print(f" [-] {requirements=}")
57 | content["prerequisities"] = requirements
58 |
59 | with file_task_meta.open('w') as f:
60 | json.dump(content, f, indent=4)
61 |
62 |
63 | def main() -> int:
64 | login = KSILogin.login_auto()
65 |
66 | backend = (login.backend_url, login.token)
67 |
68 | source = Path(argv[1])
69 | if source.is_file():
70 | replace_requiements(source, *backend)
71 | else:
72 | for task_meta in source.rglob("task.json"):
73 | replace_requiements(task_meta, *backend)
74 |
75 |
76 | if __name__ == '__main__':
77 | exit(main())
78 |
--------------------------------------------------------------------------------
/utils/util_login.py:
--------------------------------------------------------------------------------
1 | import json
2 | import subprocess
3 | from getpass import getpass
4 | from os import environ
5 | from typing import Optional, Tuple
6 | from urllib import request
7 | from urllib.error import HTTPError
8 | from urllib.parse import urlencode
9 |
10 |
11 | class KSILogin:
12 | def __init__(self, backend_url: str) -> None:
13 | self.__backend_url: str = backend_url
14 | self.__token: Optional[str] = None
15 |
16 | def __del__(self) -> None:
17 | self.logout()
18 |
19 | @property
20 | def token(self) -> str:
21 | if self.__token is None:
22 | raise ValueError('User not logged in yet, token is None')
23 | return self.__token
24 |
25 | @property
26 | def backend_url(self) -> str:
27 | return self.__backend_url
28 |
29 | def logout(self) -> None:
30 | if self.__token is None:
31 | return
32 | with request.urlopen(request.Request(f"{self.__backend_url}/logout", headers={'Authorization': self.__token})) as res:
33 | res.read()
34 |
35 | def login(self, username: str, password: Optional[str] = None) -> bool:
36 | if self.__token is not None:
37 | return True
38 |
39 | if password is None:
40 | password = getpass(f'Enter you password for account "{username}": ')
41 |
42 | req = request.Request(
43 | f"{self.__backend_url}/auth",
44 | method="POST",
45 | data=urlencode({
46 | 'username': username,
47 | 'password': password,
48 | 'grant_type': 'password',
49 | 'refresh_token': ''
50 | }).encode('utf8')
51 | )
52 | try:
53 | with request.urlopen(req) as res:
54 | # TODO: save and handle refresh token
55 | data = json.loads(res.read().decode('utf8'))
56 | self.__token = data['token_type'] + ' ' + data['access_token']
57 | return True
58 | except HTTPError:
59 | return False
60 |
61 | def construct_url(self, path: str) -> Tuple[str, dict]:
62 | return f"{self.__backend_url}/{path}", {"headers": {'Authorization': self.__token, 'Content-Type': 'application/json'}}
63 |
64 | def __enter__(self):
65 | return self
66 |
67 | def __exit__(self, exc_type, exc_val, exc_tb):
68 | self.logout()
69 |
70 | @classmethod
71 | def load_environment_from_pass(cls, entry: str) -> None:
72 | # See https://www.passwordstore.org/
73 | from subprocess import run, PIPE
74 | try:
75 | output = run(['pass', 'show', entry], text=True, stdout=PIPE, check=True)
76 | except (FileNotFoundError, subprocess.CalledProcessError):
77 | return
78 | for line in output.stdout.splitlines():
79 | key, value = line.split('=', 1)
80 | environ[key] = value
81 |
82 | @classmethod
83 | def login_auto(cls) -> "KSILogin":
84 | if environ.get('KSI_DISABLE_PASSWORD_STORE') is None:
85 | cls.load_environment_from_pass(environ.get('KSI_PASSWORD_STORE_ENTRY', 'ksi'))
86 | username = environ.get('KSI_USERNAME')
87 | password = environ.get('KSI_PASSWORD')
88 | backend = environ.get('KSI_BACKEND', 'https://rest.ksi.fi.muni.cz')
89 | if username is None:
90 | username = input('Enter your username: ')
91 | if password is None:
92 | password = getpass(f'Enter your password for account "{username}": ')
93 |
94 | instance = cls(backend)
95 | success = instance.login(username, password)
96 | if not success:
97 | raise ValueError('Login failed')
98 | return instance
99 |
--------------------------------------------------------------------------------