├── .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 | --------------------------------------------------------------------------------