├── .dockerignore ├── .envrc_template ├── .github └── workflows │ ├── deploy-dev.yaml │ └── deploy-prod.yaml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.md ├── accounts ├── __init__.py ├── admin.py ├── apps.py ├── auth.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_token.py │ ├── 0003_customuser_certificate_name.py │ └── __init__.py ├── models.py ├── templates │ └── accounts │ │ └── login.html ├── tests.py ├── urls.py └── views.py ├── add_data.py ├── add_more_test_data.py ├── add_user.py ├── course_management ├── __init__.py ├── asgi.py ├── context_processors.py ├── middleware.py ├── settings.py ├── urls.py └── wsgi.py ├── courses ├── __init__.py ├── admin.py ├── admin │ ├── __init__.py │ ├── course.py │ ├── forms.py │ ├── homework.py │ └── projects.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_enrollment_student.py │ ├── 0003_replace_commas_with_linebreaks_in_possible_answers.py │ ├── 0004_update_correct_answer_indexes.py │ ├── 0005_update_answers_with_indexes.py │ ├── 0006_course_first_homework_scored.py │ ├── 0007_enrollment_position_on_leaderboard.py │ ├── 0008_remove_answer_student.py │ ├── 0009_rename_comments_peerreview_problems_comments_and_more.py │ ├── 0010_remove_reviewcriteria_max_score.py │ ├── 0011_alter_enrollment_position_on_leaderboard.py │ ├── 0012_project_points_for_peer_review_and_more.py │ ├── 0013_remove_homework_is_scored_homework_state_and_more.py │ ├── 0014_alter_projectsubmission_github_link_and_more.py │ ├── 0015_enrollment_certificate_url.py │ ├── 0016_enrollment_about_me_enrollment_github_url_and_more.py │ ├── 0017_alter_projectsubmission_learning_in_public_links_and_more.py │ ├── 0018_course_finished.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── course.py │ ├── homework.py │ └── project.py ├── projects.py ├── random_names.py ├── scoring.py ├── static │ ├── courses.css │ ├── homework.js │ ├── leaderboard.js │ ├── learning_in_public.js │ └── local_date.js ├── templates │ ├── courses │ │ ├── course.html │ │ ├── course_list.html │ │ ├── enrollment.html │ │ ├── leaderboard.html │ │ └── leaderboard_score_breakdown.html │ ├── homework │ │ ├── homework.html │ │ └── stats.html │ ├── include │ │ └── learning_in_public_links.html │ ├── index.html │ └── projects │ │ ├── eval.html │ │ ├── eval_submit.html │ │ ├── list.html │ │ ├── project.html │ │ └── results.html ├── templatetags │ ├── __init__.py │ └── custom_filters.py ├── tests │ ├── __init__.py │ ├── test_certificate_name.py │ ├── test_course.py │ ├── test_data.py │ ├── test_homework.py │ ├── test_leaderboard.py │ ├── test_project_assign.py │ ├── test_project_eval.py │ ├── test_project_score.py │ ├── test_project_view.py │ ├── test_scoring.py │ ├── test_unit_projects.py │ ├── test_unit_scoring.py │ ├── test_unit_url_validation.py │ └── util.py ├── urls.py ├── validators │ ├── __init__.py │ ├── custom_url_validators.py │ └── validating_json_field.py └── views │ ├── __init__.py │ ├── course.py │ ├── data.py │ ├── forms.py │ ├── homework.py │ └── project.py ├── db └── .gitkeep ├── deploy ├── deploy_dev.sh ├── deploy_prod.sh └── update_task_def.py ├── entrypoint.sh ├── manage.py ├── notebooks ├── article.ipynb ├── cheater-removal.ipynb ├── competition.ipynb ├── copy-homework.ipynb ├── criteria.ipynb ├── de-zoomcamp-leaderboard.ipynb ├── graduates-two-projects.ipynb ├── graduates.ipynb ├── homework-submissions.ipynb ├── merge_submissions.ipynb ├── project-review-delete.ipynb ├── project-submission.ipynb ├── project-test-data.ipynb ├── scoring-speedup.ipynb ├── starter.ipynb ├── submitted-projects.ipynb └── time-spent-export.ipynb ├── pyproject.toml ├── templates └── base.html ├── terraform ├── Makefile ├── app.tf ├── app_dev.tf ├── app_prod.tf ├── bastion.tf ├── db.tf ├── env-template.json ├── iam_deploy.tf ├── main.tf ├── vpc.tf ├── vpc_private.tf └── vpc_public.tf └── tunnel-prod.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | db/ 2 | terraform/ 3 | Dockerfile 4 | Makefile 5 | README.md -------------------------------------------------------------------------------- /.envrc_template: -------------------------------------------------------------------------------- 1 | export DB_PASSWORD='password' 2 | export DJANGO_SECRET='secret' 3 | export SITE_ID=3 4 | export IS_LOCAL=1 5 | export AUTH_TOKEN='token' -------------------------------------------------------------------------------- /.github/workflows/deploy-dev.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy Dev 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '.github/**' 9 | - '.vscode/**' 10 | - 'deploy/**' 11 | - 'terraform/**' 12 | - 'db/**' 13 | - 'notebooks/**' 14 | 15 | jobs: 16 | build-and-deploy: 17 | runs-on: ubuntu-latest 18 | 19 | env: 20 | AWS_REGION: eu-west-1 21 | REPO_ROOT: 387546586013.dkr.ecr.eu-west-1.amazonaws.com 22 | REPO_URI: 387546586013.dkr.ecr.eu-west-1.amazonaws.com/course-management 23 | 24 | steps: 25 | - name: Prepare tags and repository info 26 | id: prep 27 | run: | 28 | DATE=$(date +'%Y%m%d-%H%M%S') 29 | SHORT_SHA=$(echo ${{ github.sha }} | cut -c 1-7) 30 | echo "TAG=${DATE}-${SHORT_SHA}" >> $GITHUB_ENV 31 | 32 | - name: Checkout code 33 | uses: actions/checkout@v4 34 | 35 | - name: Set up Python 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: '3.12.3' 39 | 40 | - name: Install dependencies 41 | run: | 42 | pip install pipenv 43 | pipenv install --deploy --ignore-pipfile 44 | 45 | - name: Run tests 46 | run: pipenv run python manage.py test courses.tests 47 | 48 | - name: Build Docker image 49 | run: docker build -t course_management:${{ env.TAG }} . 50 | 51 | - name: Log in to Amazon ECR 52 | run: | 53 | aws ecr get-login-password --region ${{ env.AWS_REGION }} | docker login --username AWS --password-stdin ${{ env.REPO_ROOT }} 54 | env: 55 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 56 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 57 | 58 | - name: Push Docker image to Amazon ECR 59 | run: | 60 | docker tag course_management:${{ env.TAG }} ${{ env.REPO_URI }}:${{ env.TAG }} 61 | docker push ${{ env.REPO_URI }}:${{ env.TAG }} 62 | 63 | - name: Deploy to Dev Environment 64 | run: bash deploy/deploy_dev.sh ${{ env.TAG }} 65 | env: 66 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 67 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 68 | AWS_DEFAULT_REGION: ${{ env.AWS_REGION }} 69 | -------------------------------------------------------------------------------- /.github/workflows/deploy-prod.yaml: -------------------------------------------------------------------------------- 1 | name: Manual Production Deployment 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | confirmProdDeploy: 7 | type: boolean 8 | description: "Confirm deployment to production" 9 | required: true 10 | default: false 11 | deployTag: 12 | type: string 13 | description: "Tag to deploy to production (optional)" 14 | required: false 15 | 16 | jobs: 17 | deploy: 18 | runs-on: ubuntu-latest 19 | 20 | env: 21 | AWS_REGION: eu-west-1 22 | 23 | steps: 24 | - name: Echo Inputs 25 | run: | 26 | echo "Confirm Production Deploy: ${{ github.event.inputs.confirmProdDeploy }}" 27 | echo "Deploy Tag: ${{ github.event.inputs.deployTag }}" 28 | 29 | - name: Checkout code 30 | uses: actions/checkout@v4.1.1 31 | 32 | - name: Execute Production Deployment Script 33 | run: bash deploy/deploy_prod.sh ${{ github.event.inputs.deployTag }} 34 | env: 35 | CONFIRM_DEPLOY: ${{ github.event.inputs.confirmProdDeploy }} 36 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 37 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 38 | AWS_DEFAULT_REGION: ${{ env.AWS_REGION }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /notebooks/data/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | 12 | /terraform/env.json 13 | /terraform/.terraform* 14 | *.tfstate 15 | *.tfstate.backup 16 | 17 | /deploy/*.json 18 | 19 | .envrc 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | cover/ 65 | 66 | # Translations 67 | *.mo 68 | *.pot 69 | 70 | # Django stuff: 71 | *.log 72 | local_settings.py 73 | db.sqlite3 74 | db.sqlite3-journal 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | .pybuilder/ 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | 93 | # IPython 94 | profile_default/ 95 | ipython_config.py 96 | 97 | # pyenv 98 | # For a library or package, you might want to ignore these files since the code is 99 | # intended to run in multiple environments; otherwise, check them in: 100 | # .python-version 101 | 102 | # pipenv 103 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 104 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 105 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 106 | # install all needed dependencies. 107 | #Pipfile.lock 108 | 109 | # poetry 110 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 111 | # This is especially recommended for binary packages to ensure reproducibility, and is more 112 | # commonly ignored for libraries. 113 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 114 | #poetry.lock 115 | 116 | # pdm 117 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 118 | #pdm.lock 119 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 120 | # in version control. 121 | # https://pdm.fming.dev/#use-with-ide 122 | .pdm.toml 123 | 124 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 125 | __pypackages__/ 126 | 127 | # Celery stuff 128 | celerybeat-schedule 129 | celerybeat.pid 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # Environments 135 | .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | # PyCharm 168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 170 | # and can be added to the global gitignore or merged into this file. For a more nuclear 171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 172 | #.idea/ 173 | 174 | # Elastic Beanstalk Files 175 | .elasticbeanstalk/* 176 | !.elasticbeanstalk/*.cfg.yml 177 | !.elasticbeanstalk/*.global.yml 178 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic", 3 | "python.testing.pytestArgs": [ 4 | ".", 5 | "-v", 6 | "-s" 7 | ], 8 | "python.testing.unittestEnabled": false, 9 | "python.testing.pytestEnabled": true, 10 | "makefile.configureOnOpen": false 11 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.3-slim 2 | 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | ENV PYTHONUNBUFFERED 1 5 | 6 | WORKDIR /code 7 | 8 | # Install dependencies 9 | COPY Pipfile Pipfile.lock ./ 10 | RUN pip install pipenv && pipenv install --system 11 | 12 | # Copy project 13 | COPY . . 14 | RUN chmod +x entrypoint.sh && \ 15 | mkdir -p static && \ 16 | python manage.py collectstatic --noinput 17 | 18 | EXPOSE 80 19 | ENTRYPOINT ["/code/entrypoint.sh"] 20 | CMD gunicorn course_management.wsgi --bind 0.0.0.0:80 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TAG := $(shell date +"%Y%m%d-%H%M%S") 2 | REPO_ROOT := 387546586013.dkr.ecr.eu-west-1.amazonaws.com 3 | REPO_URI := $(REPO_ROOT)/course-management 4 | 5 | SITE_ID ?= 2 6 | 7 | run: ## Run django server 8 | run: 9 | pipenv run python manage.py runserver 0.0.0.0:8000 10 | 11 | 12 | migrations: ## Make migrations 13 | migrations: 14 | pipenv run python manage.py makemigrations 15 | pipenv run python manage.py migrate 16 | 17 | 18 | admin: ## Add an admin user 19 | admin: 20 | pipenv run python manage.py createsuperuser 21 | 22 | 23 | user: 24 | pipenv run python add_user.py 25 | 26 | 27 | tests: ## Run tests 28 | tests: 29 | pipenv run python manage.py test courses.tests 30 | 31 | 32 | data: ## Add data to database 33 | data: migrations 34 | pipenv run python add_data.py 35 | 36 | test_data: data 37 | pipenv run python add_more_test_data.py 38 | 39 | shell: ## Run django shell 40 | shell: 41 | pipenv run python manage.py shell 42 | 43 | 44 | tunnel_prod: ## Open tunnel to prod 45 | bash tunnel-prod.sh 46 | 47 | notebook: ## Run a jupyter notebook 48 | notebook: 49 | pipenv run jupyter notebook 50 | 51 | 52 | docker_build: ## Build docker image 53 | docker_build: tests 54 | docker build -t course_management:$(TAG) . 55 | 56 | 57 | docker_run: ## Run docker container 58 | docker_run: docker_build 59 | docker run -it --rm \ 60 | -p 8000:80 \ 61 | --name course_management \ 62 | -e SITE_ID="$(SITE_ID)" \ 63 | -e DEBUG="1" \ 64 | -e DATABASE_URL="sqlite:////data/db.sqlite3" \ 65 | -e VERSION=$(TAG) \ 66 | -v `cygpath -w ${PWD}/db`:/data \ 67 | course_management:$(TAG) 68 | 69 | 70 | docker_bash: ## Run bash in docker container 71 | docker_bash: 72 | docker exec -it course_management bash 73 | 74 | 75 | docker_auth: ## Authenticate to ECR 76 | docker_auth: 77 | aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin $(REPO_ROOT) 78 | 79 | 80 | docker_publish: ## Publish docker image to ECR 81 | docker_publish: docker_build docker_auth 82 | docker tag course_management:$(TAG) $(REPO_URI):$(TAG) 83 | docker push $(REPO_URI):$(TAG) 84 | 85 | 86 | deploy_dev: ## Deploy to dev environment 87 | deploy_dev: docker_publish 88 | bash deploy/deploy_dev.sh $(TAG) 89 | 90 | 91 | deploy_prod: ## Deploy to prod environment 92 | deploy_prod: 93 | bash deploy/deploy_prod.sh 94 | 95 | 96 | 97 | help: ## Show this help. 98 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django = "*" 8 | django-allauth = {extras = ["slack"], version = "*"} 9 | requests = "*" 10 | pyjwt = "*" 11 | cryptography = "*" 12 | dj-database-url = "*" 13 | whitenoise = "*" 14 | psycopg2-binary = "*" 15 | gunicorn = "*" 16 | python-json-logger = "*" 17 | django-unfold = "*" 18 | django-loginas = "*" 19 | 20 | [dev-packages] 21 | ipython = "*" 22 | pytest-django = "*" 23 | notebook = "*" 24 | pandas = "*" 25 | tqdm = "*" 26 | 27 | [requires] 28 | python_version = "3.12" 29 | python_full_version = "3.12.3" 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Course Management System 2 | 3 | A Django-based web application designed for managing and 4 | participating in DataTalks.Club courses. This platform allows 5 | instructors to create and manage courses, assignments, and 6 | peer reviews. 7 | 8 | Students can enroll in courses, submit homework, projects 9 | and engage in peer evaluations. 10 | 11 | 12 | ## Features 13 | 14 | - **User Authentication**: Registration and login functionality for students and instructors. 15 | - **Course Management**: Instructors can create and manage courses. 16 | - **Homework and Projects**: Students can submit homework and projects; instructors can manage assignments. 17 | - **Peer Reviews**: Facilitates peer review process for project evaluations. 18 | - **Leaderboard**: Displays student rankings based on performance in courses. 19 | 20 | 21 | ## Project Structure 22 | 23 | ``` 24 | ├── accounts/ # Handles user accounts and authentication 25 | ├── course_management/ # Main project folder with settings and root configurations 26 | ├── courses/ # Main logic is here (courses, homeworks, etc) 27 | ├── templates/ # Global templates directory for the project 28 | ``` 29 | 30 | ## Running it locally 31 | 32 | ### Installing dependencies 33 | 34 | Install pipenv if you don't have it yet: 35 | 36 | ```bash 37 | pip install pipenv 38 | ``` 39 | 40 | Install the dependencies (you need Python 3.9): 41 | 42 | ```bash 43 | pipenv install 44 | ``` 45 | 46 | Activate virtual env: 47 | 48 | ```bash 49 | pipenv shell 50 | ``` 51 | 52 | ### Prepare the service 53 | 54 | Set the database to local: 55 | 56 | ```bash 57 | export DATABASE_URL="sqlite:///db/db.sqlite3" 58 | ``` 59 | 60 | Make migrations: 61 | 62 | ```bash 63 | make migrations 64 | # python manage.py migrate 65 | ``` 66 | 67 | Add an admin user: 68 | 69 | ```bash 70 | make admin 71 | # python manage.py createsuperuser 72 | ``` 73 | 74 | Add some data: 75 | 76 | ```bash 77 | make data 78 | ``` 79 | 80 | ### Running the service 81 | 82 | ```bash 83 | make run 84 | # python manage.py runserver 0.0.0.0:8000 85 | ``` 86 | 87 | ## Running with Docker 88 | 89 | Build it: 90 | 91 | ```bash 92 | docker build -t course_management . 93 | ``` 94 | 95 | Run it: 96 | 97 | ```bash 98 | docker run -d \ 99 | -p 8000:8000 \ 100 | --name course_management \ 101 | -e DATABASE_URL="sqlite:////data/db.sqlite3" \ 102 | -v ${PWD}/db:/data \ 103 | course_management 104 | ``` 105 | 106 | if you're on cygwin: 107 | 108 | ```bash 109 | docker run -it --rm \ 110 | -p 8000:8000 \ 111 | --name course_management \ 112 | -e DATABASE_URL="sqlite:////data/db.sqlite3" \ 113 | -v `cygpath -w ${PWD}/db`:/data \ 114 | course_management 115 | ``` 116 | 117 | remove the container later 118 | 119 | ```bash 120 | docker rm course_management 121 | ``` 122 | 123 | get to the container 124 | 125 | ```bash 126 | docker exec -it course_management bash 127 | ``` 128 | 129 | ## Getting the data 130 | 131 | There are `/data` endpoints for getting the data 132 | 133 | Using them: 134 | 135 | ```bash 136 | TOKEN="TEST_TOKEN" 137 | HOST="http://localhost:8000" 138 | COURSE="fake-course" 139 | HOMEWORK="hw1" 140 | 141 | curl \ 142 | -H "Authorization: ${TOKEN}" \ 143 | "${HOST}/data/${COURSE}/homework/${HOMEWORK}" 144 | ``` 145 | 146 | Make sure to run `make data` to create the admin user and 147 | data (including authentication token) 148 | 149 | 150 | ## Authentication setup 151 | 152 | If you want to authenticate with OAuth locally 153 | (not requeired for testing), do the following 154 | 155 | * Go to the admin panel (http://localhost:8000/admin) 156 | * Add a record to "Sites" 157 | * "localhost:8000" for display and domain names 158 | * note the ID of the site (probably it's "2") 159 | * Add records to "Social applications": 160 | * GoogleDTC. Provider: Google 161 | * Ask Alexey for the keys. Don't share them publicly 162 | * For the site, choose the localhost one 163 | 164 | Export `SITE_ID` (should be the ID of the localhost site): 165 | 166 | ```bash 167 | export SITE_ID=2 168 | ``` 169 | 170 | Restart the app: 171 | 172 | ```bash 173 | python manage.py runserver 0.0.0.0:8000 174 | ``` 175 | 176 | Now log out as admin and log in with Google 177 | 178 | 179 | 180 | ## DB connection 181 | 182 | Prep work 183 | 184 | ``` 185 | Host bastion-tunnel 186 | HostName 187 | User ubuntu 188 | IdentityFile c:/Users/alexe/.ssh/.pem 189 | LocalForward 5433 dev-course-management-cluster.cluster-cpj5uw8ck6vb.eu-west-1.rds.amazonaws.com:5432 190 | ServerAliveInterval 60 191 | ``` 192 | 193 | Connect to the bastion 194 | 195 | ```bash 196 | ssh bastion-tunnel 197 | ``` 198 | 199 | And then 200 | 201 | ```bash 202 | pgcli -h localhost -p 5433 -u pgusr -d coursemanagement 203 | ``` 204 | 205 | When connecting for the first time, create dev and prod schemas 206 | 207 | ```SQL 208 | CREATE DATABASE dev; 209 | CREATE DATABASE prod; 210 | ``` 211 | 212 | Django shell 213 | 214 | ```bash 215 | export DATABASE_URL="postgresql://pgusr:${DB_PASSWORD}@localhost:5433/dev" 216 | export SECRET_KEY="${DJANGO_SECRET}" 217 | 218 | pipenv run python manage.py shell 219 | ``` 220 | 221 | or 222 | 223 | ```bash 224 | export DATABASE_URL="postgresql://pgusr:${DB_PASSWORD}@localhost:5433/prod" 225 | export SECRET_KEY="${DJANGO_SECRET}" 226 | ``` 227 | 228 | Finding user with email: 229 | 230 | ```python 231 | from django.contrib.auth import get_user_model 232 | User = get_user_model() 233 | 234 | user = User.objects.get(email='test@gmail.com') 235 | ``` 236 | 237 | 238 | -------------------------------------------------------------------------------- /accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataTalksClub/course-management-platform/623299630d92b1c62638a7f46ba7dd1f5b302454/accounts/__init__.py -------------------------------------------------------------------------------- /accounts/admin.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | from django import forms 4 | from django.contrib import admin 5 | 6 | from .models import CustomUser, Token 7 | 8 | 9 | class CustomUserAdmin(admin.ModelAdmin): 10 | search_fields = ["email"] 11 | change_form_template = 'loginas/change_form.html' 12 | 13 | 14 | admin.site.register(CustomUser, CustomUserAdmin) 15 | 16 | 17 | class TokenAdminForm(forms.ModelForm): 18 | class Meta: 19 | model = Token 20 | fields = '__all__' 21 | 22 | def __init__(self, *args, **kwargs): 23 | super(TokenAdminForm, self).__init__(*args, **kwargs) 24 | if not self.instance.pk: # Check if this is a new object 25 | self.initial['key'] = secrets.token_urlsafe(16) 26 | 27 | 28 | class TokenAdmin(admin.ModelAdmin): 29 | # autocomplete_fields = ['user'] 30 | 31 | form = TokenAdminForm 32 | 33 | def formfield_for_foreignkey(self, db_field, request, **kwargs): 34 | if db_field.name == "user": 35 | kwargs["queryset"] = CustomUser.objects.filter( 36 | is_staff=True 37 | ) 38 | # Or use any condition to filter the queryset 39 | # based on permissions or other criteria 40 | return super().formfield_for_foreignkey( 41 | db_field, request, **kwargs 42 | ) 43 | 44 | 45 | admin.site.register(Token, TokenAdmin) 46 | -------------------------------------------------------------------------------- /accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "accounts" 7 | -------------------------------------------------------------------------------- /accounts/auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from functools import wraps 4 | 5 | from allauth.socialaccount.adapter import DefaultSocialAccountAdapter 6 | from allauth.account.models import EmailAddress 7 | 8 | from django.utils.crypto import get_random_string 9 | 10 | from django.contrib.auth import get_user_model 11 | 12 | from django.http import JsonResponse 13 | 14 | from .models import Token 15 | 16 | 17 | User = get_user_model() 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def generate_random_password( 23 | length=12, 24 | allowed_chars="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 25 | ): 26 | return get_random_string( 27 | length=length, allowed_chars=allowed_chars 28 | ) 29 | 30 | 31 | def extract_email(response_data): 32 | email = '' 33 | 34 | if 'email' in response_data: 35 | email = response_data['email'] 36 | 37 | if 'user' in response_data: 38 | user_data = response_data['user'] 39 | if 'email' in user_data: 40 | email = user_data['email'] 41 | 42 | return email.lower().strip() 43 | 44 | 45 | class ConsolidatingSocialAccountAdapter(DefaultSocialAccountAdapter): 46 | def pre_social_login(self, request, sociallogin): 47 | response_data = sociallogin.account.extra_data 48 | logger.info(f"OAuth response: {json.dumps(response_data)}") 49 | 50 | if sociallogin.is_existing: 51 | logger.info( 52 | f"Social login already exists for {sociallogin.user}" 53 | ) 54 | return 55 | 56 | email = None 57 | try: 58 | email = extract_email(response_data) 59 | logger.info(f"Extracted email {email} from OAuth response") 60 | if email is None or len(email) == 0: 61 | logger.info("No email found in social account data") 62 | return 63 | 64 | existing_emails = EmailAddress.objects.filter( 65 | email__iexact=email 66 | ) 67 | num_existing_emails = existing_emails.count() 68 | logger.info(f"Found {num_existing_emails} existing users for email {email}") 69 | 70 | if num_existing_emails == 0: 71 | # No existing user with this email, so create a new one 72 | logger.info( 73 | f"No existing user found with email {email}, creating a new one" 74 | ) 75 | 76 | password = generate_random_password() 77 | user = User.objects.create_user( 78 | username=email, email=email, password=password 79 | ) 80 | user.save() 81 | 82 | email_address = EmailAddress.objects.create( 83 | user=user, 84 | email=email, 85 | primary=True, 86 | verified=True, 87 | ) 88 | 89 | email_address.save() 90 | 91 | # Link the new social login to the new user 92 | sociallogin.connect(request, user) 93 | 94 | if num_existing_emails == 1: 95 | # Link the new social login to the existing user 96 | first_email = existing_emails.first() 97 | user = first_email.user 98 | 99 | logger.info( 100 | f"Found existing user with email {email}, connecting to it" 101 | ) 102 | sociallogin.connect(request, user) 103 | 104 | if num_existing_emails > 1: 105 | # Multiple users found with the same email 106 | logger.warning( 107 | f"Multiple users found with email {email} - attempting to link to the most recently active account." 108 | ) 109 | # Logic to select the most recently active account 110 | most_recent_user = self.select_most_recent_user( 111 | existing_emails 112 | ) 113 | if most_recent_user: 114 | logger.info( 115 | f"Found existing user with email {email}, connecting to it" 116 | ) 117 | sociallogin.connect(request, most_recent_user) 118 | 119 | except EmailAddress.DoesNotExist: 120 | logger.error(f"No user found with email {email}") 121 | except KeyError: 122 | logger.error("Email key not found in social account data") 123 | 124 | @staticmethod 125 | def select_most_recent_user(email_addresses): 126 | # Assuming 'last_login' can be used to determine the most recently active user 127 | users = [ 128 | email.user for email in email_addresses if email.user 129 | ] 130 | return max( 131 | users, 132 | key=lambda user: (user.last_login or user.date_joined), 133 | default=None, 134 | ) 135 | 136 | 137 | 138 | 139 | def token_required(f): 140 | @wraps(f) 141 | def decorated(request, *args, **kwargs): 142 | token_key = request.headers.get('Authorization') 143 | if token_key: 144 | token_key = token_key.replace('Token ', '', 1) # Assuming the token is sent as "Token " 145 | try: 146 | token = Token.objects.get(key=token_key) 147 | request.user = token.user 148 | except Token.DoesNotExist: 149 | return JsonResponse({'error': 'Invalid token'}, status=401) 150 | else: 151 | return JsonResponse({'error': 'Authentication token required'}, status=401) 152 | 153 | return f(request, *args, **kwargs) 154 | return decorated -------------------------------------------------------------------------------- /accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-24 17:22 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ("auth", "0012_alter_user_first_name_max_length"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="CustomUser", 20 | fields=[ 21 | ( 22 | "id", 23 | models.BigAutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ("password", models.CharField(max_length=128, verbose_name="password")), 31 | ( 32 | "last_login", 33 | models.DateTimeField( 34 | blank=True, null=True, verbose_name="last login" 35 | ), 36 | ), 37 | ( 38 | "is_superuser", 39 | models.BooleanField( 40 | default=False, 41 | help_text="Designates that this user has all permissions without explicitly assigning them.", 42 | verbose_name="superuser status", 43 | ), 44 | ), 45 | ( 46 | "username", 47 | models.CharField( 48 | error_messages={ 49 | "unique": "A user with that username already exists." 50 | }, 51 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 52 | max_length=150, 53 | unique=True, 54 | validators=[ 55 | django.contrib.auth.validators.UnicodeUsernameValidator() 56 | ], 57 | verbose_name="username", 58 | ), 59 | ), 60 | ( 61 | "first_name", 62 | models.CharField( 63 | blank=True, max_length=150, verbose_name="first name" 64 | ), 65 | ), 66 | ( 67 | "last_name", 68 | models.CharField( 69 | blank=True, max_length=150, verbose_name="last name" 70 | ), 71 | ), 72 | ( 73 | "email", 74 | models.EmailField( 75 | blank=True, max_length=254, verbose_name="email address" 76 | ), 77 | ), 78 | ( 79 | "is_staff", 80 | models.BooleanField( 81 | default=False, 82 | help_text="Designates whether the user can log into this admin site.", 83 | verbose_name="staff status", 84 | ), 85 | ), 86 | ( 87 | "is_active", 88 | models.BooleanField( 89 | default=True, 90 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 91 | verbose_name="active", 92 | ), 93 | ), 94 | ( 95 | "date_joined", 96 | models.DateTimeField( 97 | default=django.utils.timezone.now, verbose_name="date joined" 98 | ), 99 | ), 100 | ( 101 | "role", 102 | models.CharField( 103 | choices=[("student", "Student"), ("instructor", "Instructor")], 104 | default="student", 105 | max_length=10, 106 | ), 107 | ), 108 | ( 109 | "groups", 110 | models.ManyToManyField( 111 | blank=True, 112 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 113 | related_name="user_set", 114 | related_query_name="user", 115 | to="auth.group", 116 | verbose_name="groups", 117 | ), 118 | ), 119 | ( 120 | "user_permissions", 121 | models.ManyToManyField( 122 | blank=True, 123 | help_text="Specific permissions for this user.", 124 | related_name="user_set", 125 | related_query_name="user", 126 | to="auth.permission", 127 | verbose_name="user permissions", 128 | ), 129 | ), 130 | ], 131 | options={ 132 | "verbose_name": "user", 133 | "verbose_name_plural": "users", 134 | "abstract": False, 135 | }, 136 | managers=[ 137 | ("objects", django.contrib.auth.models.UserManager()), 138 | ], 139 | ), 140 | ] 141 | -------------------------------------------------------------------------------- /accounts/migrations/0002_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-04-15 19:24 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("accounts", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Token", 17 | fields=[ 18 | ( 19 | "key", 20 | models.CharField(max_length=40, primary_key=True, serialize=False), 21 | ), 22 | ( 23 | "user", 24 | models.ForeignKey( 25 | on_delete=django.db.models.deletion.CASCADE, 26 | to=settings.AUTH_USER_MODEL, 27 | ), 28 | ), 29 | ], 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /accounts/migrations/0003_customuser_certificate_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-05-12 16:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('accounts', '0002_token'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='customuser', 15 | name='certificate_name', 16 | field=models.CharField(blank=True, help_text='Your actual name that will appear on your certificates', max_length=255, null=True, verbose_name='Certificate name'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataTalksClub/course-management-platform/623299630d92b1c62638a7f46ba7dd1f5b302454/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /accounts/models.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | from django.db import models 4 | from django.contrib.auth.models import AbstractUser 5 | 6 | 7 | class CustomUser(AbstractUser): 8 | ROLE_CHOICES = ( 9 | ('student', 'Student'), 10 | ('instructor', 'Instructor'), 11 | ) 12 | 13 | role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='student') 14 | certificate_name = models.CharField( 15 | verbose_name="Certificate name", 16 | max_length=255, 17 | blank=True, 18 | null=True, 19 | help_text="Your actual name that will appear on your certificates" 20 | ) 21 | 22 | 23 | class Token(models.Model): 24 | key = models.CharField(max_length=40, primary_key=True) 25 | user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) 26 | 27 | def save(self, *args, **kwargs): 28 | if not self.key: 29 | self.key = secrets.token_urlsafe(16) 30 | super().save(*args, **kwargs) 31 | 32 | def __str__(self): 33 | return self.key -------------------------------------------------------------------------------- /accounts/templates/accounts/login.html: -------------------------------------------------------------------------------- 1 | {% for provider in providers %} 2 | Login with {{ provider.name }}
3 | {% endfor %} -------------------------------------------------------------------------------- /accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | 4 | urlpatterns = [ 5 | path('login/', views.social_login_view, name='login'), 6 | path('signup/', views.social_login_view, name='accounts_signup'), 7 | path('email/', views.disabled), 8 | path('password/reset/', views.disabled), 9 | ] -------------------------------------------------------------------------------- /accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from asgiref.sync import sync_to_async 3 | from allauth.socialaccount.providers import registry 4 | from allauth.socialaccount.models import SocialApp 5 | 6 | from django.core.cache import cache 7 | 8 | from django.conf import settings 9 | from django.urls import reverse 10 | from django.http import HttpResponse 11 | 12 | 13 | def disabled(request): 14 | return HttpResponse("This URL is disabled", status=403) 15 | 16 | 17 | async def social_login_view(request): 18 | providers = await get_available_providers() 19 | 20 | return render( 21 | request, 22 | "accounts/login.html", 23 | {"providers": providers}, 24 | ) 25 | 26 | 27 | @sync_to_async 28 | def get_available_providers(): 29 | # Check if the data is in the cache 30 | cached_providers = cache.get('available_providers') 31 | if cached_providers is not None: 32 | return cached_providers 33 | 34 | providers = [] 35 | 36 | site_id = settings.SITE_ID 37 | 38 | for provider, name in registry.as_choices(): 39 | if not SocialApp.objects.filter( 40 | provider=provider, sites__id__exact=site_id 41 | ).exists(): 42 | continue 43 | 44 | login_url = reverse(f"{provider}_login") 45 | providers.append({"name": name, "login_url": login_url}) 46 | 47 | cache.set('available_providers', providers, 60 * 60) # Cache for 60 minutes 48 | 49 | return providers 50 | -------------------------------------------------------------------------------- /add_user.py: -------------------------------------------------------------------------------- 1 | import os 2 | import django 3 | import string 4 | import random 5 | 6 | os.environ.setdefault( 7 | "DJANGO_SETTINGS_MODULE", "course_management.settings" 8 | ) 9 | django.setup() 10 | 11 | from django.contrib.auth import get_user_model # noqa: E402 12 | 13 | User = get_user_model() 14 | 15 | 16 | def generate_password(length=12): 17 | characters = string.ascii_letters + string.digits 18 | return "".join(random.choice(characters) for _ in range(length)) 19 | 20 | 21 | def create_user(): 22 | print("Creating a new Django user") 23 | email = input("Enter email: ") 24 | 25 | password = generate_password() 26 | 27 | try: 28 | user = User.objects.create_user( 29 | username=email, email=email, password=password 30 | ) 31 | print(f"User with email {email} created successfully.") 32 | print(f"Generated password: {password}") 33 | print("Please make sure to save this password securely.") 34 | 35 | is_superuser = ( 36 | input("Make this user a superuser? (y/n): ").lower() == "y" 37 | ) 38 | if is_superuser: 39 | user.is_superuser = True 40 | user.is_staff = True 41 | user.save() 42 | print("User has been granted superuser privileges.") 43 | 44 | except django.db.utils.IntegrityError: 45 | print(f"A user with email {email} already exists.") 46 | 47 | 48 | if __name__ == "__main__": 49 | create_user() 50 | -------------------------------------------------------------------------------- /course_management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataTalksClub/course-management-platform/623299630d92b1c62638a7f46ba7dd1f5b302454/course_management/__init__.py -------------------------------------------------------------------------------- /course_management/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for course_management project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "course_management.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /course_management/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def export_settings(request): 5 | return { 6 | "VERSION": settings.VERSION 7 | } 8 | -------------------------------------------------------------------------------- /course_management/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.http import HttpResponse 4 | 5 | 6 | class RequestHeaderLoggerMiddleware: 7 | def __init__(self, get_response): 8 | self.get_response = get_response 9 | self.logger = logging.getLogger(__name__) 10 | 11 | def __call__(self, request): 12 | self.logger.info(f"Headers: {request.headers}") 13 | return self.get_response(request) 14 | 15 | 16 | class HealthCheckMiddleware: 17 | def __init__(self, get_response): 18 | self.get_response = get_response 19 | 20 | def __call__(self, request): 21 | # Bypass ALLOWED_HOSTS check for health check endpoint 22 | if request.path.startswith('/ping') and request.method == 'GET': 23 | return HttpResponse("OK") 24 | return self.get_response(request) -------------------------------------------------------------------------------- /course_management/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path('admin/', include('loginas.urls')), 6 | path('admin/', admin.site.urls), 7 | 8 | path("accounts/", include("accounts.urls")), 9 | path("accounts/", include("allauth.urls")), 10 | 11 | path("", include("courses.urls")), 12 | ] 13 | -------------------------------------------------------------------------------- /course_management/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for course_management project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "course_management.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /courses/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataTalksClub/course-management-platform/623299630d92b1c62638a7f46ba7dd1f5b302454/courses/__init__.py -------------------------------------------------------------------------------- /courses/admin.py: -------------------------------------------------------------------------------- 1 | from .admin import * -------------------------------------------------------------------------------- /courses/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from importlib import import_module 3 | 4 | 5 | def import_admin_modules(): 6 | # we just want to import all the modules, 7 | # so in admin.py we can use the star import 8 | current_dir = Path(__file__).resolve().parent 9 | 10 | for python_file in current_dir.glob("*.py"): 11 | if python_file.name == "__init__.py": 12 | continue 13 | 14 | module_name = f"courses.admin.{python_file.stem}" 15 | 16 | try: 17 | import_module(module_name) 18 | except ImportError as e: 19 | print(f"Failed to import {module_name}: {e}") 20 | 21 | 22 | import_admin_modules() 23 | -------------------------------------------------------------------------------- /courses/admin/course.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import admin 3 | from django.utils import timezone 4 | from unfold.admin import ModelAdmin, TabularInline 5 | from unfold.widgets import ( 6 | UnfoldAdminTextInputWidget, 7 | UnfoldAdminTextareaWidget, 8 | ) 9 | 10 | from django.contrib import messages 11 | 12 | from courses.models import Course, ReviewCriteria 13 | from courses.scoring import update_leaderboard 14 | 15 | 16 | class CriteriaForm(forms.ModelForm): 17 | class Meta: 18 | model = ReviewCriteria 19 | fields = "__all__" 20 | widgets = { 21 | "description": UnfoldAdminTextInputWidget( 22 | attrs={"size": "60"} 23 | ), 24 | "options": UnfoldAdminTextareaWidget( 25 | attrs={"cols": 60, "rows": 4} 26 | ), 27 | } 28 | 29 | 30 | class CriteriaInline(TabularInline): 31 | model = ReviewCriteria 32 | form = CriteriaForm 33 | extra = 0 34 | 35 | 36 | def update_leaderboard_admin(modeladmin, request, queryset): 37 | for course in queryset: 38 | update_leaderboard(course) 39 | modeladmin.message_user( 40 | request, 41 | f"Leaderboard updated for course {course}", 42 | level=messages.SUCCESS, 43 | ) 44 | 45 | 46 | update_leaderboard_admin.short_description = "Update leaderboard" 47 | 48 | 49 | def duplicate_course(modeladmin, request, queryset): 50 | current_year = timezone.now().year 51 | 52 | for course in queryset: 53 | # Create a new course instance 54 | old_title = course.title 55 | new_title = old_title.replace( 56 | str(current_year - 1), str(current_year) 57 | ) 58 | if str(current_year - 1) not in old_title: 59 | new_title = f"{old_title} {current_year}" 60 | 61 | old_slug = course.slug 62 | new_slug = old_slug.replace( 63 | str(current_year - 1), str(current_year) 64 | ) 65 | if str(current_year - 1) not in old_slug: 66 | new_slug = f"{old_slug}-{current_year}" 67 | 68 | # Create new course with updated fields 69 | new_course = Course.objects.create( 70 | title=new_title, 71 | slug=new_slug, 72 | description=course.description, 73 | social_media_hashtag=course.social_media_hashtag, 74 | first_homework_scored=False, 75 | finished=False, 76 | faq_document_url=course.faq_document_url, 77 | ) 78 | 79 | # Copy review criteria with all fields 80 | for criteria in course.reviewcriteria_set.all(): 81 | ReviewCriteria.objects.create( 82 | course=new_course, 83 | description=criteria.description, 84 | options=criteria.options, 85 | review_criteria_type=criteria.review_criteria_type, 86 | ) 87 | 88 | modeladmin.message_user( 89 | request, 90 | f"Course '{old_title}' was duplicated to '{new_title}'", 91 | level=messages.SUCCESS, 92 | ) 93 | 94 | 95 | duplicate_course.short_description = "Duplicate selected courses" 96 | 97 | 98 | @admin.register(Course) 99 | class CourseAdmin(ModelAdmin): 100 | actions = [update_leaderboard_admin, duplicate_course] 101 | inlines = [CriteriaInline] 102 | list_display = ["title"] 103 | -------------------------------------------------------------------------------- /courses/admin/forms.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataTalksClub/course-management-platform/623299630d92b1c62638a7f46ba7dd1f5b302454/courses/admin/forms.py -------------------------------------------------------------------------------- /courses/admin/homework.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import admin 3 | from unfold.admin import ModelAdmin, TabularInline 4 | from unfold.widgets import ( 5 | UnfoldAdminTextInputWidget, 6 | UnfoldAdminTextareaWidget, 7 | ) 8 | from django.contrib import messages 9 | 10 | from courses.models import Homework, Question, HomeworkState 11 | 12 | from courses.scoring import ( 13 | score_homework_submissions, 14 | fill_correct_answers, 15 | calculate_homework_statistics, 16 | ) 17 | 18 | 19 | class QuestionForm(forms.ModelForm): 20 | class Meta: 21 | model = Question 22 | fields = "__all__" 23 | widgets = { 24 | "text": UnfoldAdminTextInputWidget(attrs={"size": "60"}), 25 | "possible_answers": UnfoldAdminTextareaWidget( 26 | attrs={"cols": 60, "rows": 4} 27 | ), 28 | "correct_answer": UnfoldAdminTextInputWidget( 29 | attrs={"size": "20"} 30 | ), 31 | } 32 | 33 | 34 | class QuestionInline(TabularInline): 35 | model = Question 36 | form = QuestionForm 37 | extra = 0 38 | 39 | 40 | def score_selected_homeworks(modeladmin, request, queryset): 41 | for homework in queryset: 42 | status, message = score_homework_submissions(homework.id) 43 | if status: 44 | modeladmin.message_user( 45 | request, message, level=messages.SUCCESS 46 | ) 47 | else: 48 | modeladmin.message_user( 49 | request, message, level=messages.WARNING 50 | ) 51 | 52 | 53 | score_selected_homeworks.short_description = "Score selected homeworks" 54 | 55 | 56 | def set_most_popular_as_correct(modeladmin, request, queryset): 57 | for homework in queryset: 58 | fill_correct_answers(homework) 59 | modeladmin.message_user( 60 | request, 61 | f"Correct answer for {homework} set to most popular", 62 | level=messages.SUCCESS, 63 | ) 64 | 65 | 66 | set_most_popular_as_correct.short_description = ( 67 | "Set correct answers to most popular" 68 | ) 69 | 70 | 71 | def calculate_statistics_selected_homeworks( 72 | modeladmin, request, queryset 73 | ): 74 | for homework in queryset: 75 | if homework.state != HomeworkState.SCORED.value: 76 | modeladmin.message_user( 77 | request, 78 | f"Cannot calculate statistics for {homework} " 79 | "because it has not been scored", 80 | level=messages.WARNING, 81 | ) 82 | continue 83 | 84 | calculate_homework_statistics(homework, force=True) 85 | 86 | message = f"Statistics calculated for {homework}" 87 | modeladmin.message_user( 88 | request, message, level=messages.SUCCESS 89 | ) 90 | 91 | 92 | calculate_statistics_selected_homeworks.short_description = ( 93 | "Calculate statistics" 94 | ) 95 | 96 | 97 | @admin.register(Homework) 98 | class HomeworkAdmin(ModelAdmin): 99 | inlines = [QuestionInline] 100 | actions = [ 101 | score_selected_homeworks, 102 | set_most_popular_as_correct, 103 | calculate_statistics_selected_homeworks, 104 | ] 105 | list_display = ["title", "course", "due_date", "state"] 106 | list_filter = ["course__slug"] 107 | -------------------------------------------------------------------------------- /courses/admin/projects.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from unfold.admin import ModelAdmin 3 | 4 | from django.contrib import messages 5 | 6 | from courses.models import Project, ReviewCriteria 7 | 8 | from courses.projects import ( 9 | assign_peer_reviews_for_project, 10 | score_project, 11 | ProjectActionStatus, 12 | ) 13 | 14 | 15 | def assign_peer_reviews_for_project_admin( 16 | modeladmin, request, queryset 17 | ): 18 | for project in queryset: 19 | status, message = assign_peer_reviews_for_project(project) 20 | if status == ProjectActionStatus.OK: 21 | modeladmin.message_user( 22 | request, message, level=messages.SUCCESS 23 | ) 24 | else: 25 | modeladmin.message_user( 26 | request, message, level=messages.WARNING 27 | ) 28 | 29 | 30 | assign_peer_reviews_for_project_admin.short_description = ( 31 | "Assign peer reviews" 32 | ) 33 | 34 | 35 | def score_projects_admin(modeladmin, request, queryset): 36 | for project in queryset: 37 | status, message = score_project(project) 38 | if status == ProjectActionStatus.OK: 39 | modeladmin.message_user( 40 | request, message, level=messages.SUCCESS 41 | ) 42 | else: 43 | modeladmin.message_user( 44 | request, message, level=messages.WARNING 45 | ) 46 | 47 | 48 | score_projects_admin.short_description = "Score projects" 49 | 50 | 51 | @admin.register(Project) 52 | class ProjectAdmin(ModelAdmin): 53 | actions = [ 54 | assign_peer_reviews_for_project_admin, 55 | score_projects_admin, 56 | ] 57 | 58 | list_display = ["title", "course", "state"] 59 | list_filter = ["course__slug"] 60 | 61 | 62 | @admin.register(ReviewCriteria) 63 | class ReviewCriteriaAdmin(ModelAdmin): 64 | pass 65 | -------------------------------------------------------------------------------- /courses/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoursesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "courses" 7 | -------------------------------------------------------------------------------- /courses/migrations/0002_alter_enrollment_student.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-01-19 12:12 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("courses", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="enrollment", 18 | name="student", 19 | field=models.ForeignKey( 20 | on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /courses/migrations/0003_replace_commas_with_linebreaks_in_possible_answers.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-02-06 21:07 2 | 3 | from django.db import migrations 4 | 5 | def replace_commas_with_linebreaks(apps, schema_editor): 6 | Question = apps.get_model('courses', 'Question') 7 | for question in Question.objects.all(): 8 | if question.possible_answers: 9 | question.possible_answers = question.possible_answers.replace(',', '\n') 10 | question.save() 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | ('courses', '0002_alter_enrollment_student'), 16 | ] 17 | 18 | operations = [ 19 | migrations.RunPython(replace_commas_with_linebreaks), 20 | ] 21 | -------------------------------------------------------------------------------- /courses/migrations/0004_update_correct_answer_indexes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-02-06 21:42 2 | 3 | from django.db import migrations 4 | 5 | import logging 6 | 7 | from courses.models import Question, QuestionTypes 8 | 9 | logger = logging.getLogger("courses.migrations") 10 | 11 | 12 | def replace_answers_with_indexes(possible_answers, correct_answers, question_id=None): 13 | possible_answers = [ 14 | answer.strip().lower() for answer in possible_answers 15 | ] 16 | correct_answers = correct_answers.lower().strip() 17 | 18 | logger.debug(f"Possible answers: {possible_answers}") 19 | logger.debug(f"Correct answers: {correct_answers}") 20 | 21 | correct_indexes = [] 22 | 23 | for answer in correct_answers.split(","): 24 | answer = answer.strip() 25 | try: 26 | zero_based_index = possible_answers.index(answer) 27 | index = zero_based_index + 1 28 | correct_indexes.append(str(index)) 29 | except ValueError: 30 | logger.error( 31 | f"Answer '{answer}' not found in possible_answers for question ID {question_id}" 32 | ) 33 | 34 | result = ",".join(correct_indexes) 35 | logger.debug(f"Corrected result: {result}") 36 | return result 37 | 38 | 39 | def update_correct_answers_to_indexes(apps, schema_editor): 40 | for question in Question.objects.all(): 41 | if question.question_type not in [ 42 | QuestionTypes.MULTIPLE_CHOICE.value, 43 | QuestionTypes.CHECKBOXES.value, 44 | ]: 45 | continue 46 | 47 | if question.possible_answers and question.correct_answer: 48 | possible_answers = question.get_possible_answers() 49 | 50 | correct_indexes = replace_answers_with_indexes( 51 | possible_answers, question.correct_answer, question.id 52 | ) 53 | 54 | question.correct_answer = correct_indexes 55 | question.save() 56 | 57 | 58 | class Migration(migrations.Migration): 59 | dependencies = [ 60 | ( 61 | "courses", 62 | "0003_replace_commas_with_linebreaks_in_possible_answers", 63 | ), 64 | ] 65 | 66 | operations = [ 67 | migrations.RunPython(update_correct_answers_to_indexes), 68 | ] 69 | -------------------------------------------------------------------------------- /courses/migrations/0005_update_answers_with_indexes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.10 on 2024-02-11 16:11 2 | import logging 3 | 4 | from django.db import migrations 5 | 6 | from courses.models import Answer, QuestionTypes 7 | 8 | logger = logging.getLogger("courses.migrations") 9 | 10 | 11 | def replace_answers_with_indexes(possible_answers, answers, question_id=None): 12 | possible_answers = [ 13 | answer.strip().lower() for answer in possible_answers 14 | ] 15 | answers = answers.lower().strip() 16 | 17 | logger.debug(f"Possible answers: {possible_answers}") 18 | logger.debug(f"User answers: {answers}") 19 | 20 | correct_indexes = [] 21 | 22 | for answer in answers.split(","): 23 | answer = answer.strip() 24 | try: 25 | zero_based_index = possible_answers.index(answer) 26 | index = zero_based_index + 1 27 | correct_indexes.append(str(index)) 28 | except ValueError: 29 | logger.error( 30 | f"Answer '{answer}' not found in possible_answers for question ID {question_id}" 31 | ) 32 | 33 | result = ",".join(correct_indexes) 34 | logger.debug(f"Corrected result: {result}") 35 | return result 36 | 37 | 38 | def update_answers_with_indexes(apps, schema_editor): 39 | updated_answers = [] 40 | 41 | for answer in Answer.objects.all(): 42 | question = answer.question 43 | if question.question_type not in [ 44 | QuestionTypes.MULTIPLE_CHOICE.value, 45 | QuestionTypes.CHECKBOXES.value, 46 | ]: 47 | continue 48 | 49 | if question.possible_answers and answer.answer_text: 50 | possible_answers = question.get_possible_answers() 51 | 52 | updated_answer = replace_answers_with_indexes( 53 | possible_answers, answer.answer_text, question.id 54 | ) 55 | 56 | answer.answer_text = updated_answer 57 | updated_answers.append(answer) 58 | 59 | logger.debug( 60 | f"Updated answer ID {answer.id} with index/indices: {updated_answer}" 61 | ) 62 | 63 | Answer.objects.bulk_update(updated_answers, ["answer_text"]) 64 | 65 | 66 | class Migration(migrations.Migration): 67 | dependencies = [ 68 | ("courses", "0004_update_correct_answer_indexes"), 69 | ] 70 | 71 | operations = [ 72 | migrations.RunPython(update_answers_with_indexes), 73 | ] 74 | -------------------------------------------------------------------------------- /courses/migrations/0006_course_first_homework_scored.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.10 on 2024-02-13 15:35 2 | 3 | from django.db import migrations, models 4 | 5 | from courses.models import Course 6 | 7 | def set_first_homework_scored_true_for_existing_records(apps, schema_editor): 8 | Course.objects.all().update(first_homework_scored=True) 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ("courses", "0005_update_answers_with_indexes"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name="course", 19 | name="first_homework_scored", 20 | field=models.BooleanField( 21 | help_text="Whether the first homework has been scored. We use that for deciding whether to show the leaderboard.", 22 | default=False, 23 | ), 24 | ), 25 | migrations.RunPython(set_first_homework_scored_true_for_existing_records), 26 | ] 27 | -------------------------------------------------------------------------------- /courses/migrations/0007_enrollment_position_on_leaderboard.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.10 on 2024-02-13 16:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("courses", "0006_course_first_homework_scored"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="enrollment", 15 | name="position_on_leaderboard", 16 | field=models.IntegerField(blank=True, default=0, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /courses/migrations/0008_remove_answer_student.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.10 on 2024-02-15 18:55 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("courses", "0007_enrollment_position_on_leaderboard"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="answer", 15 | name="student", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /courses/migrations/0009_rename_comments_peerreview_problems_comments_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.10 on 2024-03-16 21:40 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("courses", "0008_remove_answer_student"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RenameField( 15 | model_name="peerreview", 16 | old_name="comments", 17 | new_name="problems_comments", 18 | ), 19 | migrations.RemoveField( 20 | model_name="criteriaresponse", 21 | name="score", 22 | ), 23 | migrations.AddField( 24 | model_name="criteriaresponse", 25 | name="answer", 26 | field=models.CharField(blank=True, max_length=255, null=True), 27 | ), 28 | migrations.AddField( 29 | model_name="peerreview", 30 | name="optional", 31 | field=models.BooleanField(default=False), 32 | ), 33 | migrations.AddField( 34 | model_name="peerreview", 35 | name="state", 36 | field=models.CharField( 37 | choices=[("TR", "TO_REVIEW"), ("SU", "SUBMITTED")], 38 | default="TR", 39 | max_length=2, 40 | ), 41 | ), 42 | migrations.AddField( 43 | model_name="peerreview", 44 | name="submitted_at", 45 | field=models.DateTimeField(blank=True, null=True), 46 | ), 47 | migrations.AlterField( 48 | model_name="peerreview", 49 | name="reviewer", 50 | field=models.ForeignKey( 51 | on_delete=django.db.models.deletion.CASCADE, 52 | related_name="reviewers", 53 | to="courses.projectsubmission", 54 | ), 55 | ), 56 | migrations.AlterField( 57 | model_name="peerreview", 58 | name="submission_under_evaluation", 59 | field=models.ForeignKey( 60 | on_delete=django.db.models.deletion.CASCADE, 61 | related_name="reviews_under_evaluation", 62 | to="courses.projectsubmission", 63 | ), 64 | ), 65 | migrations.AlterField( 66 | model_name="peerreview", 67 | name="time_spent_reviewing", 68 | field=models.FloatField(blank=True, null=True), 69 | ), 70 | migrations.AlterField( 71 | model_name="projectsubmission", 72 | name="time_spent", 73 | field=models.FloatField(blank=True, null=True), 74 | ), 75 | ] 76 | -------------------------------------------------------------------------------- /courses/migrations/0010_remove_reviewcriteria_max_score.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.10 on 2024-03-19 15:52 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("courses", "0009_rename_comments_peerreview_problems_comments_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="reviewcriteria", 15 | name="max_score", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /courses/migrations/0011_alter_enrollment_position_on_leaderboard.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-03-25 14:41 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("courses", "0010_remove_reviewcriteria_max_score"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="enrollment", 15 | name="position_on_leaderboard", 16 | field=models.IntegerField(blank=True, default=None, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /courses/migrations/0012_project_points_for_peer_review_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-04-12 09:46 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("courses", "0011_alter_enrollment_position_on_leaderboard"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="project", 16 | name="points_for_peer_review", 17 | field=models.IntegerField(default=3), 18 | ), 19 | migrations.AddField( 20 | model_name="projectsubmission", 21 | name="passed", 22 | field=models.BooleanField(default=False), 23 | ), 24 | migrations.AddField( 25 | model_name="projectsubmission", 26 | name="peer_review_learning_in_public_score", 27 | field=models.IntegerField(default=0), 28 | ), 29 | migrations.AddField( 30 | model_name="projectsubmission", 31 | name="peer_review_score", 32 | field=models.IntegerField(default=0), 33 | ), 34 | migrations.AddField( 35 | model_name="projectsubmission", 36 | name="project_faq_score", 37 | field=models.IntegerField(default=0), 38 | ), 39 | migrations.AddField( 40 | model_name="projectsubmission", 41 | name="project_learning_in_public_score", 42 | field=models.IntegerField(default=0), 43 | ), 44 | migrations.AddField( 45 | model_name="projectsubmission", 46 | name="project_score", 47 | field=models.IntegerField(default=0), 48 | ), 49 | migrations.AddField( 50 | model_name="projectsubmission", 51 | name="reviewed_enough_peers", 52 | field=models.BooleanField(default=False), 53 | ), 54 | migrations.AddField( 55 | model_name="projectsubmission", 56 | name="total_score", 57 | field=models.IntegerField(default=0), 58 | ), 59 | migrations.CreateModel( 60 | name="ProjectEvaluationScore", 61 | fields=[ 62 | ( 63 | "id", 64 | models.BigAutoField( 65 | auto_created=True, 66 | primary_key=True, 67 | serialize=False, 68 | verbose_name="ID", 69 | ), 70 | ), 71 | ("score", models.IntegerField()), 72 | ( 73 | "review_criteria", 74 | models.ForeignKey( 75 | on_delete=django.db.models.deletion.CASCADE, 76 | to="courses.reviewcriteria", 77 | ), 78 | ), 79 | ( 80 | "submission", 81 | models.ForeignKey( 82 | on_delete=django.db.models.deletion.CASCADE, 83 | to="courses.projectsubmission", 84 | ), 85 | ), 86 | ], 87 | ), 88 | ] 89 | -------------------------------------------------------------------------------- /courses/migrations/0013_remove_homework_is_scored_homework_state_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-05-08 14:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("courses", "0012_project_points_for_peer_review_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="homework", 15 | name="is_scored", 16 | ), 17 | migrations.AddField( 18 | model_name="homework", 19 | name="state", 20 | field=models.CharField( 21 | choices=[("CL", "CLOSED"), ("OP", "OPEN"), ("SC", "SCORED")], 22 | default="OP", 23 | max_length=2, 24 | ), 25 | ), 26 | migrations.AlterField( 27 | model_name="project", 28 | name="state", 29 | field=models.CharField( 30 | choices=[ 31 | ("CL", "CLOSED"), 32 | ("CS", "COLLECTING_SUBMISSIONS"), 33 | ("PR", "PEER_REVIEWING"), 34 | ("CO", "COMPLETED"), 35 | ], 36 | default="CS", 37 | max_length=2, 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /courses/migrations/0014_alter_projectsubmission_github_link_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.14 on 2024-07-31 05:05 2 | 3 | import courses.validators.custom_url_validators 4 | import courses.validators.validating_json_field 5 | import django.core.validators 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('courses', '0013_remove_homework_is_scored_homework_state_and_more'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='projectsubmission', 18 | name='github_link', 19 | field=models.URLField(validators=[django.core.validators.URLValidator(), courses.validators.custom_url_validators.validate_url_200]), 20 | ), 21 | migrations.AlterField( 22 | model_name='projectsubmission', 23 | name='learning_in_public_links', 24 | field=courses.validators.validating_json_field.ValidatingJSONField(blank=True, null=True), 25 | ), 26 | migrations.AlterField( 27 | model_name='submission', 28 | name='homework_link', 29 | field=models.URLField(blank=True, null=True, validators=[django.core.validators.URLValidator(schemes=['http', 'https', 'git']), courses.validators.custom_url_validators.validate_url_200]), 30 | ), 31 | migrations.AlterField( 32 | model_name='submission', 33 | name='learning_in_public_links', 34 | field=courses.validators.validating_json_field.ValidatingJSONField(blank=True, help_text='Links where students talk about the course', null=True), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /courses/migrations/0015_enrollment_certificate_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.14 on 2024-08-28 12:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('courses', '0014_alter_projectsubmission_github_link_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='enrollment', 15 | name='certificate_url', 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /courses/migrations/0016_enrollment_about_me_enrollment_github_url_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.14 on 2024-08-28 14:31 2 | 3 | import courses.validators.custom_url_validators 4 | import django.core.validators 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('courses', '0015_enrollment_certificate_url'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='enrollment', 17 | name='about_me', 18 | field=models.TextField(blank=True, help_text='You can put any information about yourself here', null=True, verbose_name='About me'), 19 | ), 20 | migrations.AddField( 21 | model_name='enrollment', 22 | name='github_url', 23 | field=models.URLField(blank=True, null=True, validators=[django.core.validators.URLValidator(), courses.validators.custom_url_validators.validate_url_200], verbose_name='GitHub URL'), 24 | ), 25 | migrations.AddField( 26 | model_name='enrollment', 27 | name='linkedin_url', 28 | field=models.URLField(blank=True, null=True, validators=[django.core.validators.URLValidator(), courses.validators.custom_url_validators.validate_url_200], verbose_name='LinkedIn URL'), 29 | ), 30 | migrations.AddField( 31 | model_name='enrollment', 32 | name='personal_website_url', 33 | field=models.URLField(blank=True, null=True, validators=[django.core.validators.URLValidator(), courses.validators.custom_url_validators.validate_url_200], verbose_name='Personal website URL'), 34 | ), 35 | migrations.AlterField( 36 | model_name='enrollment', 37 | name='certificate_name', 38 | field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Name for the certificate'), 39 | ), 40 | migrations.AlterField( 41 | model_name='enrollment', 42 | name='display_name', 43 | field=models.CharField(blank=True, max_length=255, verbose_name='Leaderboard name'), 44 | ), 45 | migrations.AlterField( 46 | model_name='enrollment', 47 | name='about_me', 48 | field=models.TextField(blank=True, help_text='Any information about you', null=True, verbose_name='About me'), 49 | ), 50 | migrations.AlterField( 51 | model_name='enrollment', 52 | name='certificate_name', 53 | field=models.CharField(blank=True, help_text='Your actual name that will appear on your certificate', max_length=255, null=True, verbose_name='Certificate name'), 54 | ), 55 | migrations.AlterField( 56 | model_name='enrollment', 57 | name='display_name', 58 | field=models.CharField(blank=True, help_text='Name on the leaderboard', max_length=255, verbose_name='Leaderboard name'), 59 | ), 60 | migrations.AlterField( 61 | model_name='enrollment', 62 | name='github_url', 63 | field=models.URLField(blank=True, null=True, validators=[django.core.validators.URLValidator()], verbose_name='GitHub URL'), 64 | ), 65 | migrations.AlterField( 66 | model_name='enrollment', 67 | name='linkedin_url', 68 | field=models.URLField(blank=True, null=True, validators=[django.core.validators.URLValidator()], verbose_name='LinkedIn URL'), 69 | ), 70 | migrations.AlterField( 71 | model_name='enrollment', 72 | name='personal_website_url', 73 | field=models.URLField(blank=True, null=True, validators=[django.core.validators.URLValidator()], verbose_name='Personal website URL'), 74 | ), 75 | ] 76 | -------------------------------------------------------------------------------- /courses/migrations/0017_alter_projectsubmission_learning_in_public_links_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.14 on 2024-10-10 12:48 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('courses', '0016_enrollment_about_me_enrollment_github_url_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='projectsubmission', 16 | name='learning_in_public_links', 17 | field=models.JSONField(blank=True, null=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='submission', 21 | name='learning_in_public_links', 22 | field=models.JSONField(blank=True, help_text='Links where students talk about the course', null=True), 23 | ), 24 | migrations.CreateModel( 25 | name='HomeworkStatistics', 26 | fields=[ 27 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('total_submissions', models.IntegerField(default=0)), 29 | ('min_questions_score', models.IntegerField(blank=True, null=True)), 30 | ('max_questions_score', models.IntegerField(blank=True, null=True)), 31 | ('avg_questions_score', models.FloatField(blank=True, null=True)), 32 | ('median_questions_score', models.FloatField(blank=True, null=True)), 33 | ('q1_questions_score', models.FloatField(blank=True, null=True)), 34 | ('q3_questions_score', models.FloatField(blank=True, null=True)), 35 | ('min_total_score', models.IntegerField(blank=True, null=True)), 36 | ('max_total_score', models.IntegerField(blank=True, null=True)), 37 | ('avg_total_score', models.FloatField(blank=True, null=True)), 38 | ('median_total_score', models.FloatField(blank=True, null=True)), 39 | ('q1_total_score', models.FloatField(blank=True, null=True)), 40 | ('q3_total_score', models.FloatField(blank=True, null=True)), 41 | ('min_learning_in_public_score', models.IntegerField(blank=True, null=True)), 42 | ('max_learning_in_public_score', models.IntegerField(blank=True, null=True)), 43 | ('avg_learning_in_public_score', models.FloatField(blank=True, null=True)), 44 | ('median_learning_in_public_score', models.FloatField(blank=True, null=True)), 45 | ('q1_learning_in_public_score', models.FloatField(blank=True, null=True)), 46 | ('q3_learning_in_public_score', models.FloatField(blank=True, null=True)), 47 | ('min_time_spent_lectures', models.FloatField(blank=True, null=True)), 48 | ('max_time_spent_lectures', models.FloatField(blank=True, null=True)), 49 | ('avg_time_spent_lectures', models.FloatField(blank=True, null=True)), 50 | ('median_time_spent_lectures', models.FloatField(blank=True, null=True)), 51 | ('q1_time_spent_lectures', models.FloatField(blank=True, null=True)), 52 | ('q3_time_spent_lectures', models.FloatField(blank=True, null=True)), 53 | ('min_time_spent_homework', models.FloatField(blank=True, null=True)), 54 | ('max_time_spent_homework', models.FloatField(blank=True, null=True)), 55 | ('avg_time_spent_homework', models.FloatField(blank=True, null=True)), 56 | ('median_time_spent_homework', models.FloatField(blank=True, null=True)), 57 | ('q1_time_spent_homework', models.FloatField(blank=True, null=True)), 58 | ('q3_time_spent_homework', models.FloatField(blank=True, null=True)), 59 | ('last_calculated', models.DateTimeField(auto_now=True)), 60 | ('homework', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='statistics', to='courses.homework')), 61 | ], 62 | ), 63 | ] 64 | -------------------------------------------------------------------------------- /courses/migrations/0018_course_finished.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.14 on 2024-10-15 12:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('courses', '0017_alter_projectsubmission_learning_in_public_links_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='course', 15 | name='finished', 16 | field=models.BooleanField(default=False, help_text='Whether the course has finished.'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /courses/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataTalksClub/course-management-platform/623299630d92b1c62638a7f46ba7dd1f5b302454/courses/migrations/__init__.py -------------------------------------------------------------------------------- /courses/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import course, project, homework 2 | 3 | from .course import * # noqa: F403 4 | from .homework import * # noqa: F403 5 | from .project import * # noqa: F403 6 | 7 | from django.contrib.auth import get_user_model 8 | 9 | User = get_user_model() -------------------------------------------------------------------------------- /courses/models/course.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django.core.validators import URLValidator 4 | from accounts.models import CustomUser 5 | 6 | from courses.random_names import generate_random_name 7 | 8 | User = CustomUser 9 | 10 | 11 | class Course(models.Model): 12 | slug = models.SlugField(unique=True, blank=False) 13 | title = models.CharField(max_length=200) 14 | 15 | description = models.TextField() 16 | students = models.ManyToManyField( 17 | User, through="Enrollment", related_name="courses_enrolled" 18 | ) 19 | 20 | social_media_hashtag = models.CharField( 21 | max_length=100, 22 | blank=True, 23 | help_text="The hashtag associated with the course for social media use.", 24 | ) 25 | 26 | first_homework_scored = models.BooleanField( 27 | default=False, 28 | blank=False, 29 | help_text="Whether the first homework has been scored. " 30 | + "We use that for deciding whether to show the leaderboard.", 31 | ) 32 | 33 | finished = models.BooleanField( 34 | default=False, 35 | blank=False, 36 | help_text="Whether the course has finished.", 37 | ) 38 | 39 | faq_document_url = models.URLField( 40 | blank=True, 41 | validators=[URLValidator()], 42 | help_text="The URL of the FAQ document for the course.", 43 | ) 44 | 45 | def __str__(self): 46 | return self.title 47 | 48 | 49 | class Enrollment(models.Model): 50 | class Meta: 51 | unique_together = ["student", "course"] 52 | 53 | student = models.ForeignKey(User, on_delete=models.CASCADE) 54 | course = models.ForeignKey(Course, on_delete=models.CASCADE) 55 | enrollment_date = models.DateTimeField(auto_now_add=True) 56 | 57 | display_name = models.CharField( 58 | verbose_name="Leaderboard name", max_length=255, blank=True, 59 | help_text="Name on the leaderboard" 60 | ) 61 | display_on_leaderboard = models.BooleanField(default=True) 62 | 63 | position_on_leaderboard = models.IntegerField( 64 | blank=True, null=True, default=None 65 | ) 66 | 67 | certificate_name = models.CharField( 68 | verbose_name="Certificate name", 69 | max_length=255, 70 | blank=True, 71 | null=True, 72 | help_text="Your actual name that will appear on your certificate" 73 | ) 74 | 75 | total_score = models.IntegerField(default=0) 76 | 77 | certificate_url = models.CharField( 78 | max_length=255, null=True, blank=True 79 | ) 80 | 81 | github_url = models.URLField( 82 | verbose_name="GitHub URL", 83 | blank=True, 84 | null=True, 85 | validators=[URLValidator()], 86 | ) 87 | linkedin_url = models.URLField( 88 | verbose_name="LinkedIn URL", 89 | blank=True, 90 | null=True, 91 | validators=[URLValidator()], 92 | ) 93 | personal_website_url = models.URLField( 94 | verbose_name="Personal website URL", 95 | blank=True, 96 | null=True, 97 | validators=[URLValidator()], 98 | ) 99 | about_me = models.TextField( 100 | verbose_name="About me", 101 | blank=True, 102 | null=True, 103 | help_text="Any information about you", 104 | ) 105 | 106 | def save(self, *args, **kwargs): 107 | if not self.display_name: 108 | self.display_name = generate_random_name() 109 | 110 | # If certificate_name is being set, update the user's certificate_name 111 | if self.certificate_name and self.certificate_name != self.student.certificate_name: 112 | self.student.certificate_name = self.certificate_name 113 | self.student.save() 114 | # If certificate_name is not set but user has one, use the user's certificate_name 115 | elif not self.certificate_name and self.student.certificate_name: 116 | self.certificate_name = self.student.certificate_name 117 | 118 | super().save(*args, **kwargs) 119 | 120 | def __str__(self): 121 | return f"{self.student} enrolled in {self.course}" 122 | -------------------------------------------------------------------------------- /courses/static/courses.css: -------------------------------------------------------------------------------- 1 | /* Apply to the header nav or a div inside the header if it wraps your content */ 2 | header nav { 3 | max-width: 720px; 4 | margin: 0 auto; /* Centers the navigation horizontally */ 5 | padding: 20px 0; /* Vertical padding to match your content block */ 6 | display: flex; 7 | justify-content: space-between; /* This will space your list and logout/login */ 8 | align-items: center; /* This will align the items vertically */ 9 | } 10 | 11 | /* Ensure the lists within the nav take up the appropriate space */ 12 | header nav ul { 13 | display: flex; 14 | align-items: center; /* Align list items vertically */ 15 | padding: 0; 16 | margin: 0; /* Reset margins to control spacing explicitly */ 17 | } 18 | 19 | header nav li:not(:last-child) { 20 | margin-right: 1rem; /* Adds margin to the right of each list item except the last */ 21 | } 22 | 23 | nav .breadcrumbs ul { 24 | list-style: none; 25 | padding: 0; 26 | display: flex; 27 | align-items: center; 28 | } 29 | 30 | /* Style each list item */ 31 | nav .breadcrumbs ul li { 32 | margin-right: 0.25rem; /* Add some space between the breadcrumbs */ 33 | } 34 | 35 | /* Add ">" before each li except the first one using a pseudo-element */ 36 | nav .breadcrumbs ul li:not(:first-child)::before { 37 | content: "\f105"; /* FontAwesome's right arrow icon */ 38 | font-family: "Font Awesome 5 Free"; 39 | color: #000; 40 | padding-right: 0.25rem; /* Add some space before the breadcrumb text */ 41 | font-weight: 900; /* Bold */ 42 | } 43 | 44 | 45 | .form-container { 46 | max-width: 720px; 47 | margin: 20px auto; 48 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); 49 | padding: 20px; 50 | } 51 | 52 | .form-container .question { 53 | padding-top: 1rem; 54 | } 55 | 56 | .form-container .question .question-text { 57 | margin-bottom: 0.5rem; 58 | } 59 | 60 | /* Questions */ 61 | .form-check-label { 62 | display: block; 63 | color: #5f6368; 64 | } 65 | 66 | .form-control { 67 | box-shadow: none; /* Remove Bootstrap's default shadow */ 68 | border-color: #e0e0e0; /* Light grey border color */ 69 | border-radius: 8px; /* Rounded corners */ 70 | } 71 | 72 | /* Focused Inputs */ 73 | .form-control:focus { 74 | border-color: #a6c8ff; /* Highlight color when focused */ 75 | box-shadow: 0 0 0 0.2rem rgba(102, 175, 233, .25); 76 | } 77 | 78 | /* Buttons */ 79 | .btn-primary { 80 | background-color: #1a73e8; /* Google's blue */ 81 | border-color: #1a73e8; /* Google's blue */ 82 | box-shadow: none; /* Remove Bootstrap's default shadow */ 83 | } 84 | 85 | .btn-primary:hover { 86 | background-color: #1669c7; /* Darker blue on hover */ 87 | border-color: #1669c7; /* Darker blue on hover */ 88 | } 89 | 90 | /* Adjustments for smaller screens */ 91 | @media (max-width: 576px) { 92 | .form-container { 93 | max-width: 100%; 94 | padding: 15px; /* Smaller padding on smaller screens */ 95 | } 96 | } 97 | 98 | .text-muted a, .text-muted a:visited, .text-muted a:hover, .text-muted a:focus, .text-muted a:active { 99 | color: inherit; /* This makes the link use the color of the parent .text-muted class */ 100 | } 101 | 102 | #learning-in-public-links .form-control { 103 | margin-bottom: 0.2rem; 104 | } 105 | 106 | .alert p { 107 | margin-bottom: 0; 108 | } 109 | 110 | 111 | .option-answer-correct { 112 | background-color: lightgreen; 113 | } 114 | 115 | .option-answer-incorrect { 116 | background-color: lightcoral; 117 | } 118 | 119 | .container .row { 120 | border-bottom: 1px solid #f8f8f8; /* subtle bottom border for each row */ 121 | } 122 | 123 | .container .row:last-child { 124 | border-bottom: none; /* remove border for the last row */ 125 | } 126 | 127 | /* Highlighting for the current student's row */ 128 | .bg-info { 129 | background-color: #d1ecf1 !important; /* Adjust color as needed */ 130 | } 131 | 132 | .tooltip-icon { 133 | margin-left: 4px; /* Adjust as needed */ 134 | } 135 | 136 | .question .question-image { 137 | display: block; 138 | max-width: 100%; 139 | height: auto; 140 | margin-bottom: 8px; 141 | border-radius: 4px; 142 | } 143 | 144 | .error-message { 145 | color: #856404; 146 | background-color: #fff3cd; 147 | border: 1px solid #ffeeba; 148 | border-radius: 0.25rem; 149 | padding: 0.75rem 1.25rem; 150 | margin-top: 0.5rem; 151 | font-size: 0.875rem; 152 | } 153 | 154 | .error-message i { 155 | margin-right: 0.5rem; 156 | } 157 | 158 | .question input.is-invalid, 159 | .question textarea.is-invalid, 160 | .question select.is-invalid { 161 | border-color: #dc3545; 162 | } 163 | 164 | .social-icon { 165 | font-size: 24px; 166 | color: #808080; 167 | margin-right: 10px; 168 | transition: color 0.3s ease; 169 | } 170 | 171 | .social-icon:hover { 172 | color: #000000; 173 | } 174 | 175 | .toggle-lip { 176 | margin-left: 10px; 177 | cursor: pointer; 178 | } 179 | 180 | .lip-links { 181 | display: none; 182 | margin-top: 10px; 183 | } -------------------------------------------------------------------------------- /courses/static/homework.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | 3 | var isValidUrl = function(urlString) { 4 | try { 5 | var url = new URL(urlString); 6 | var validProtocol = url.protocol === "http:" || url.protocol === "https:"; 7 | return validProtocol; 8 | } catch (e) { 9 | return false; 10 | } 11 | }; 12 | 13 | var isValidCommitId = function(commitId) { 14 | var commitIdPattern = /^[0-9a-f]{7}$/i; 15 | return commitIdPattern.test(commitId); 16 | }; 17 | 18 | var validateUrlField = function(selector, name, optional) { 19 | var linkField = $(selector); 20 | if (linkField.length == 0) { 21 | /* the field doesn't exist */ 22 | return ''; 23 | } 24 | 25 | var errorMessage = ''; 26 | var link = linkField.val(); 27 | 28 | if (!link) { 29 | if (optional) { 30 | return ''; 31 | } 32 | errorMessage += (name + ' link URL is missing.\n'); 33 | } else if (!isValidUrl(link)) { 34 | errorMessage += (name + ' link URL is invalid. It should start with http:// or https://\n'); 35 | } 36 | return errorMessage; 37 | }; 38 | 39 | var validateCommitIdField = function(selector) { 40 | var commitField = $(selector); 41 | if (commitField.length == 0) { 42 | /* the field doesn't exist */ 43 | return ''; 44 | } 45 | 46 | var errorMessage = ''; 47 | var commitId = commitField.val(); 48 | if (!commitId) { 49 | errorMessage += 'Commit ID is missing.\n'; 50 | } else if (!isValidCommitId(commitId)) { 51 | errorMessage += 'Commit ID is invalid. It should be a 7-character hexadecimal string. For example, "468aacb"\n'; 52 | } 53 | return errorMessage; 54 | }; 55 | 56 | $('#submit-button').click(function (event) { 57 | var isValid = true; 58 | var errorMessage = ''; 59 | 60 | var urlFieldsToValidate = [ 61 | ["#homework_url", "Homework", false], 62 | ["#github_link", "GitHub link", false], 63 | ["#id_github_url", "GitHub profile link", true], 64 | ["#id_linkedin_url", "LinkedIn profile link", true], 65 | ["#id_personal_website_url", "Personal website link", true], 66 | ]; 67 | 68 | for (var i = 0; i < urlFieldsToValidate.length; i++) { 69 | var selector = urlFieldsToValidate[i][0]; 70 | var description = urlFieldsToValidate[i][1]; 71 | var optional = urlFieldsToValidate[i][1]; 72 | 73 | var message = validateUrlField(selector, description, optional); 74 | 75 | if (message) { 76 | isValid = false; 77 | errorMessage += message; 78 | } 79 | } 80 | 81 | var commitIdMessage = validateCommitIdField('#commit_id'); 82 | if (commitIdMessage) { 83 | isValid = false; 84 | errorMessage += commitIdMessage; 85 | } 86 | 87 | $('input[name="learning_in_public_links[]"]').each(function () { 88 | var link = $(this).val(); 89 | if (link && !isValidUrl(link)) { 90 | isValid = false; 91 | errorMessage += 'Invalid learning in public link URL.\n'; 92 | } 93 | }); 94 | 95 | if (!isValid) { 96 | alert(errorMessage); 97 | event.preventDefault(); // Prevent form submission 98 | } 99 | }); 100 | 101 | }); -------------------------------------------------------------------------------- /courses/static/leaderboard.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | $('.toggle-lip').click(function () { 3 | var targetId = $(this).data('target'); 4 | $('#' + targetId).slideToggle(); 5 | 6 | var $icon = $(this).find('i'); 7 | var $text = $(this).find('span.fas-show'); 8 | 9 | if ($icon.hasClass('fa-chevron-down')) { 10 | $icon.removeClass('fa-chevron-down').addClass('fa-chevron-up'); 11 | $text.text("Hide"); 12 | } else { 13 | $icon.removeClass('fa-chevron-up').addClass('fa-chevron-down'); 14 | $text.text("Show"); 15 | } 16 | }); 17 | }); -------------------------------------------------------------------------------- /courses/static/learning_in_public.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | $('#add-learning-public-link').click(function () { 3 | let currentLinkCount = $('#learning-in-public-links input[type="url"]').length; 4 | let cap = global_learning_in_public_cap; 5 | if (currentLinkCount < cap) { 6 | let html = ''; 7 | $('#learning-in-public-links').append(html); 8 | } 9 | if (currentLinkCount + 1 >= cap) { 10 | $(this).prop('disabled', true); 11 | } 12 | }); 13 | }); -------------------------------------------------------------------------------- /courses/static/local_date.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | var utc_dates = document.querySelectorAll(".local-date"); 3 | 4 | utc_dates.forEach((date) => { 5 | var formattedDate = new Date(date.innerHTML); 6 | 7 | var day = formattedDate.getDate(); 8 | var month = formattedDate.toLocaleString('default', { month: 'long' }); 9 | var year = formattedDate.getFullYear(); 10 | var hours = formattedDate.getHours().toString().padStart(2, '0'); 11 | var minutes = formattedDate.getMinutes().toString().padStart(2, '0'); 12 | 13 | var formattedDateString = `${day} ${month} ${year} ${hours}:${minutes}`; 14 | 15 | date.innerHTML = formattedDateString; 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /courses/templates/courses/course.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load tz %} 4 | {% load custom_filters %} 5 | 6 | {% block breadcrumbs %} 7 |
  • {{ course.title }}
  • 8 | {% endblock %} 9 | 10 | {% block content %} 11 |

    {{ course.title }}

    12 | 13 |
    14 |

    {{ course.description | urlize_target_blank }}

    15 |
    16 | 17 |
    18 |

    19 | {% if course.first_homework_scored %} 20 | Course leaderboard 21 | {% endif %} 22 | {% if is_authenticated %} 23 | Edit course profile 24 | {% endif %} 25 |

    26 |
    27 | 28 | 29 | {% if is_authenticated and course.first_homework_scored %} 30 | 33 | {% endif %} 34 | 35 | {% if homeworks %} 36 |
    37 |

    Homework

    38 | 39 | {% for hw in homeworks %} 40 |
    41 |
    42 |
    43 | {% if hw.state == 'CL' %} 44 | {{ hw.title }} 45 | {% else %} 46 | 47 | {{ hw.title }} 48 | 49 | {% endif %} 50 |
    51 |
    52 | {{ hw.due_date | date:"c" }} 53 |
    54 |
    55 | {% if hw.state == 'CL' %} 56 | Closed 57 | {% elif hw.is_scored and hw.submitted %} 58 | Scored ({{ hw.score }}) 59 | {% elif hw.is_scored %} 60 | Scored 61 | {% elif not hw.is_scored and hw.submitted %} 62 | Submitted 63 | {% else %} 64 | Open 65 | {% endif %} 66 |
    67 |
    68 |
    69 | {% endfor %} 70 |
    71 | {% endif %} 72 | 73 | {% if projects %} 74 |
    75 |

    Projects

    76 | {% for project in projects %} 77 |
    78 |
    79 |
    80 | {% if project.state == 'CS' %} 81 | 82 | {{ project.title }} 83 | 84 | {% elif project.state == 'PR' %} 85 | 86 | {{ project.title }} 87 | 88 | {% elif project.state == 'CO' %} 89 | 90 | {{ project.title }} 91 | 92 | {% else %} 93 | {{ project.title }} 94 | {% endif %} 95 |
    96 | {% if project.state == 'CS' or project.state == 'CL' %} 97 |
    98 | {{ project.submission_due_date | date:"c" }} 99 |
    100 | {% elif project.state == 'PR' %} 101 |
    102 | {{ project.peer_review_due_date | date:"c" }} 103 |
    104 | {% elif project.state == 'CO' %} 105 |
    106 | {{ project.peer_review_due_date | date:"c" }} 107 |
    108 | {% else %} 109 |
    110 | {{ project.submission_due_date | date:"c" }} 111 |
    112 | {% endif %} 113 |
    114 | {{ project.badge_state_name }} 115 |
    116 |
    117 |
    118 | {% endfor %} 119 |
    120 | {% endif %} 121 | 122 | {% if not homeworks and not projects %} 123 | 126 | {% endif %} 127 | 128 |
    All deadlines are in your local timezone
    129 | 130 | 135 | 136 | {% endblock %} -------------------------------------------------------------------------------- /courses/templates/courses/course_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |

    DataTalks.Club courses

    5 |
    6 | Welcome to our course management platform! You can learn more about 7 | our courses in our webpage https://datatalks.club/. 8 |
    9 | 10 |

    Active courses

    11 | 16 | 17 |

    Finished courses

    18 |
      19 | {% for course in finished_courses %} 20 |
    • {{ course.title }}
    • 21 | {% endfor %} 22 |
    23 | 24 | {% endblock %} -------------------------------------------------------------------------------- /courses/templates/courses/enrollment.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block breadcrumbs %} 6 |
  • {{ course.title }}
  • 7 | {% endblock %} 8 | 9 | {% block content %} 10 | 11 | 12 |

    Edit Enrollment Details

    13 | 14 | 15 |
    16 |
    17 | {% csrf_token %} 18 | 19 | {% for field in form %} 20 |
    21 | 34 | {{ field }} 35 | {% if field.errors %} 36 |
    37 | 38 | {{ field.errors|join:" " }} 39 |
    40 | {% endif %} 41 |
    42 | {% endfor %} 43 | 44 |

    45 | Leaderboard name appears on the leaderboard. It's an auto-generated 46 | random name. You can edit it to be your nickname, or your real 47 | name. But you can also keep it. 48 |

    49 |

    50 | Certificate name is your actual name that you want to 51 | appear on your certificate. If you don't set it, your 52 | leaderboard name will be used for the certificate. 53 |

    54 | 55 |
    56 | 62 |
    63 | 64 |
    65 |
    66 | 67 |

    Enrollment information

    68 |
    69 |

    Certificate: 70 | {% if enrollment.certificate_url %} 71 | Download 72 | {% else %} 73 | Not available 74 | {% endif %} 75 |

    76 |
    77 | 78 |
    79 | Back to {{ course.title }} 80 |
    81 | 82 | 87 | 88 | 89 | 90 | {% endblock %} -------------------------------------------------------------------------------- /courses/templates/courses/leaderboard.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block breadcrumbs %} 4 |
  • {{ course.title }}
  • 5 | {% endblock %} 6 | 7 | {% block content %} 8 | 9 | {% if current_student_enrollment %} 10 |
    11 |

    Your Record

    12 |
    13 |
    14 | 15 | Your total score: {{ current_student_enrollment.total_score }} 16 | 17 | (Position: {{ current_student_enrollment.position_on_leaderboard }})
    18 |
    Display name: {{ current_student_enrollment.display_name }} (change)
    19 | 20 |
    21 |
    22 | {% endif %} 23 | 24 |
    25 |
    #
    26 |
    Name
    27 |
    Score
    28 |
    29 | 30 | {% for enrollment in enrollments %} 31 |
    32 |
    {{ enrollment.position_on_leaderboard }}
    33 | 38 |
    {{ enrollment.total_score }}
    39 |
    40 | {% endfor %} 41 | 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /courses/templates/courses/leaderboard_score_breakdown.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block breadcrumbs %} 6 |
  • {{ enrollment.course.title }}
  • 7 |
  • Leaderboard
  • 8 | {% endblock %} 9 | 10 | {% block content %} 11 | 12 |

    {{ enrollment.display_name }}

    13 | 14 | {% if enrollment.about_me %} 15 |

    About me: {{enrollment.about_me}}

    16 | {% endif %} 17 | 18 |

    19 | {% if enrollment.github_url %} 20 | 21 | {% endif %} 22 | {% if enrollment.linkedin_url %} 23 | 24 | {% endif %} 25 | {% if enrollment.personal_website_url %} 26 | 27 | {% endif %} 28 |

    29 | 30 |

    Total Score: {{ enrollment.total_score }}

    31 | 32 |

    Homework submissions

    33 | {% for submission in submissions %} 34 | {% if submission.homework.is_scored %} 35 |
    36 |

    {{ submission.homework.title }}

    37 |

    38 | Score: {{ submission.total_score }} = 39 | {{ submission.questions_score }} (questions) + 40 | {{ submission.faq_score }} (FAQ) + 41 | {{ submission.learning_in_public_score }} (learning in public) 42 |

    43 | {% if submission.homework_link %} 44 |

    Homework URL: View submission

    45 | {% endif %} 46 | {% if submission.learning_in_public_links %} 47 |

    48 | Learning in public links: 49 | 50 | Show 51 | 52 |

    53 | 58 | {% endif %} 59 |
    60 | {% endif %} 61 | {% empty %} 62 |

    No submissions yet.

    63 | {% endfor %} 64 | 65 |

    Project submissions

    66 | {% for submission in project_submissions %} 67 | {% if submission.project.state == 'CO' %} 68 |
    69 |

    {{ submission.project.title }}

    70 |

    71 | Project score: {{ submission.project_score }} 72 | {% if submission.passed %} 73 | Passed 74 | {% else %} 75 | Failed 76 | {% endif %} 77 |

    78 |

    79 | Score: {{ submission.total_score }} = 80 | {{ submission.project_score }} (project) + 81 | {{ submission.peer_review_score }} (peer review) + 82 | {{ submission.project_learning_in_public_score }} (learning in public / project) + 83 | {{ submission.peer_review_learning_in_public_score }} (learning in public / peer review) + 84 | {{ submission.project_faq_score }} (FAQ) 85 |

    86 | {% if submission.github_link %} 87 |

    Project URL: View project

    88 | {% endif %} 89 | {% if submission.learning_in_public_links %} 90 |

    91 | Learning in public links: 92 | 93 | Show 94 | 95 |

    96 | 101 | {% endif %} 102 |
    103 | {% endif %} 104 | 105 | {% empty %} 106 |

    No project submissions found.

    107 | {% endfor %} 108 | 109 |
    110 | Back to Leaderboard 111 |
    112 | 113 | 114 | 115 | {% endblock %} -------------------------------------------------------------------------------- /courses/templates/homework/stats.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block breadcrumbs %} 6 |
  • {{ course.title }}
  • 7 |
  • {{ homework.title }}
  • 8 | 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |

    14 | {{ homework.title }} statistics 15 |

    16 | 17 |
    18 |
    19 |
    20 | Total submissions 21 |
    22 |

    {{ stats.total_submissions }}

    23 |
    24 |
    25 | 26 | 27 | {% for field_name, field_stats, field_icon in stats.get_stat_fields %} 28 |
    29 |
    30 |
    31 | {{ field_name }} 32 |
    33 |
    34 |
    35 |
    36 | {% for value, label, icon in field_stats %} 37 |
    38 |
    {{ label }}
    39 |

    {{ value|floatformat:0 }}

    40 |
    41 | {% endfor %} 42 |
    43 |
    44 |
    45 | {% endfor %} 46 | 47 |

    48 | Calculated: {{ stats.last_calculated }} 49 |

    50 | 51 | 52 | {% endblock %} -------------------------------------------------------------------------------- /courses/templates/include/learning_in_public_links.html: -------------------------------------------------------------------------------- 1 | {% if learning_in_public_cap > 0 %} 2 | 28 | 29 | 32 | {% endif %} 33 | 34 | 35 | -------------------------------------------------------------------------------- /courses/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | {% if user.is_authenticated %} 5 |

    Welcome, {{ user.username }}!

    6 | {% else %} 7 |

    Welcome to the Course Management Platform!

    8 |

    Please log in to access your courses.

    9 | {% endif %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /courses/templates/projects/eval.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block breadcrumbs %} 6 |
  • {{ course.title }}
  • 7 |
  • {{ project.title }}
  • 8 | {% endblock %} 9 | 10 | {% block content %} 11 | 12 | {% if messages %} 13 | {% for message in messages %} 14 |
    15 |

    {{ message }}

    16 |
    17 | {% endfor %} 18 | {% endif %} 19 | 20 |

    Evaluations for {{ project.title }}

    21 | 22 | {% if not is_authenticated %} 23 | 26 | {% elif not has_submission and project.state == 'PR' %} 27 | 35 | {% else %} 36 | 50 | 51 |
    52 | Completed {{ number_of_completed_evaluation }} out of {{ project.number_of_peers_to_evaluate }} mandatory project evaluations. 53 | {% if number_of_completed_evaluation == project.number_of_peers_to_evaluate %} 54 | All mandatory evaluations completed! 55 | {% else %} 56 | Note that in order to pass this project, you need to finish all mandatory evaluations. 57 | {% endif %} 58 |
    59 | 60 | {% endif %} 61 | 62 | 68 | 69 | {% endblock %} -------------------------------------------------------------------------------- /courses/templates/projects/list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block breadcrumbs %} 6 |
  • {{ course.title }}
  • 7 |
  • {{ project.title }}
  • 8 | {% endblock %} 9 | 10 | {% block content %} 11 | 12 | 13 |

    Projects of {{ project.title }}

    14 | 15 | {% if project.state == 'CL' %} 16 |
    17 | The project is closed. Come back later to see the submissions. 18 |
    19 | 20 | {% else %} 21 | 22 | {% for submission in submissions %} 23 |
    24 |
    25 | 26 |
    27 | {{ submission.enrollment.display_name }} 28 | 33 | 34 | 35 |
    36 |
    37 | 38 |
    39 | {% if is_authenticated and project.state == 'PR' %} 40 | {% if submission.to_evaluate %} 41 | 42 | Evaluate {{ submission.review.get_state_display }} 43 | 44 | {% elif submission.own %} 45 | Your project 46 | {% elif has_submission %} 47 | 50 | {% endif %} 51 | {% elif project.state == 'CO' %} 52 | {{ submission.project_score }} 53 | {% endif %} 54 |
    55 |
    56 |
    57 | {% endfor %} 58 | {% endif %} 59 | 60 | 67 | 68 | {% endblock %} -------------------------------------------------------------------------------- /courses/templates/projects/results.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block breadcrumbs %} 6 |
  • {{ course.title }}
  • 7 |
  • {{ project.title }}
  • 8 | {% endblock %} 9 | 10 | {% block content %} 11 | 12 |

    Results for {{ project.title }}

    13 | 14 | {% if not is_authenticated %} 15 | 18 | {% elif not submission %} 19 | 22 | {% else %} 23 |
    24 |
    25 |
      26 |
    • Total score: {{ submission.total_score }}
    • 27 |
    • Project link: {{ submission.github_link }}
    • 28 |
    • Commit ID: {{ submission.commit_id }}
    • 29 |
    • Submitted at: {{ submission.submitted_at }}
    • 30 |
    • Reviewed enough peers: {{ submission.reviewed_enough_peers|yesno:"Yes,No" }}
    • 31 |
    • Status: {{ submission.passed|yesno:"Passed,Failed" }}
    • 32 |
    33 | 34 |
    35 |

    Scores Breakdown:

    36 | {% for score in scores %} 37 |
    38 | {{ score.review_criteria.description }}: 39 |
      40 | {% for option in score.review_criteria.options %} 41 |
    • {{ option.criteria }} ({{ option.score }} points)
    • 42 | {% endfor %} 43 |
    44 |

    Score Received: {{ score.score }}

    45 |
    46 | {% endfor %} 47 |
    48 | 49 |
    50 |

    Score Components:

    51 |
      52 |
    • Project score: {{ submission.project_score }}
    • 53 |
    • Peer review score: {{ submission.peer_review_score }}
    • 54 |
    • Learning in public (project): {{ submission.project_learning_in_public_score }}
    • 55 |
    • Learning in public (peer review): {{ submission.peer_review_learning_in_public_score }}
    • 56 |
    • FAQ contribution score: {{ submission.project_faq_score }}
    • 57 |
    58 |
    59 | 60 | 61 | {% if feedback %} 62 |
    63 |

    Feedback:

    64 |
      65 | {% for review in feedback %} 66 |
    • {{ review.note_to_peer }}
    • 67 | {% endfor %} 68 |
    69 |
    70 | {% endif %} 71 | 72 |
    73 |
    74 | 75 |
    76 | See all projects 77 |
    78 | 79 | {% endif %} 80 | 81 | 87 | 88 | 89 |
    90 | Back to {{ course.title }} 91 |
    92 | 93 | 94 | 95 | {% endblock %} -------------------------------------------------------------------------------- /courses/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataTalksClub/course-management-platform/623299630d92b1c62638a7f46ba7dd1f5b302454/courses/templatetags/__init__.py -------------------------------------------------------------------------------- /courses/templatetags/custom_filters.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.template.defaultfilters import stringfilter 3 | from django.utils.safestring import mark_safe 4 | from django.utils.html import urlize as urlize_impl 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.filter(is_safe=True, needs_autoescape=True) 10 | @stringfilter 11 | def urlize_target_blank(value, limit=30, autoescape=None): 12 | return mark_safe( 13 | urlize_impl( 14 | value, 15 | trim_url_limit=int(limit), 16 | nofollow=True, 17 | autoescape=autoescape, 18 | ).replace(" str: 5 | return QUESTION_ANSWER_DELIMITER.join(possible_answers) -------------------------------------------------------------------------------- /courses/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import course 4 | from .views import homework 5 | from .views import project 6 | from .views import data 7 | 8 | 9 | urlpatterns = [ 10 | path("", course.course_list, name="course_list"), 11 | path( 12 | "/", 13 | course.course_view, 14 | name="course", 15 | ), 16 | path( 17 | "/leaderboard", 18 | course.leaderboard_view, 19 | name="leaderboard", 20 | ), 21 | path( 22 | "/leaderboard//", 23 | course.leaderboard_score_breakdown_view, 24 | name="leaderboard_score_breakdown", 25 | ), 26 | path( 27 | "/enrollment", 28 | course.enrollment_view, 29 | name="enrollment", 30 | ), 31 | 32 | # project 33 | path( 34 | "/project/", 35 | project.project_view, 36 | name="project", 37 | ), 38 | path( 39 | "/project//list", 40 | project.projects_list_view, 41 | name="project_list", 42 | ), 43 | path( 44 | "/project//eval", 45 | project.projects_eval_view, 46 | name="projects_eval", 47 | ), 48 | path( 49 | "/project//results", 50 | project.project_results, 51 | name="project_results", 52 | ), 53 | path( 54 | "/project//eval/", 55 | project.projects_eval_submit, 56 | name="projects_eval_submit", 57 | ), 58 | path( 59 | "/project//eval/add/", 60 | project.projects_eval_add, 61 | name="projects_eval_add", 62 | ), 63 | path( 64 | "/project//eval/delete/", 65 | project.projects_eval_delete, 66 | name="projects_eval_delete", 67 | ), 68 | 69 | # homework 70 | path( 71 | "/homework/", 72 | homework.homework_view, 73 | name="homework", 74 | ), 75 | path( 76 | "/homework//stats", 77 | homework.homework_statistics, 78 | name="homework_statistics", 79 | ), 80 | 81 | # API 82 | path( 83 | "data//homework/", 84 | data.homework_data_view, 85 | name="data_homework", 86 | ), 87 | path( 88 | "data//project/", 89 | data.project_data_view, 90 | name="data_project", 91 | ), 92 | ] 93 | -------------------------------------------------------------------------------- /courses/validators/__init__.py: -------------------------------------------------------------------------------- 1 | from .custom_url_validators import * 2 | from .validating_json_field import * -------------------------------------------------------------------------------- /courses/validators/custom_url_validators.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.core.validators import URLValidator 5 | 6 | 7 | def get_error_message(status_code, url): 8 | if status_code != 404: 9 | return ( 10 | f"The submitted link {url} does not " 11 | + "return a 200 status code. Status code: " 12 | + f"{status_code}." 13 | ) 14 | 15 | # 404 status code 16 | if "github" in url.lower(): 17 | return ( 18 | f"The submitted GitHub link {url} does not " 19 | + "exist. Make sure the repository is public." 20 | ) 21 | 22 | return f"The submitted link {url} does not exist." 23 | 24 | 25 | def validate_url_200( 26 | url, get_method=requests.get, code=None, params=None 27 | ): 28 | try: 29 | response = get_method(url) 30 | status_code = response.status_code 31 | if status_code == 200: 32 | return 33 | error_message = get_error_message(status_code, url) 34 | raise ValidationError(error_message, code=code, params=params) 35 | except requests.exceptions.RequestException as e: 36 | raise ValidationError( 37 | f"An error occurred while trying to validate the URL: {e}", 38 | code=code, 39 | params=params, 40 | ) 41 | 42 | 43 | class Status200UrlValidator(URLValidator): 44 | def __call__(self, value): 45 | print(f"validating {value}") 46 | super().__call__(value) 47 | validate_url_200(value, code=self.code, params={"value": value}) 48 | -------------------------------------------------------------------------------- /courses/validators/validating_json_field.py: -------------------------------------------------------------------------------- 1 | from django.core import validators 2 | from django.core.exceptions import ValidationError 3 | from django.db import models 4 | from courses.validators import validate_url_200 5 | 6 | 7 | class ValidatingJSONField(models.JSONField): 8 | def validate(self, value, model_instance): 9 | super().validate(value, model_instance) 10 | 11 | for link in value: 12 | # Validate each URL in the JSON array data 13 | try: 14 | validators.URLValidator()(link) 15 | validate_url_200(link) 16 | except ValidationError as e: 17 | raise ValidationError(e.message) 18 | -------------------------------------------------------------------------------- /courses/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataTalksClub/course-management-platform/623299630d92b1c62638a7f46ba7dd1f5b302454/courses/views/__init__.py -------------------------------------------------------------------------------- /courses/views/data.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | from accounts.auth import token_required 3 | from django.shortcuts import get_object_or_404 4 | from django.db.models import Prefetch 5 | 6 | from courses.models import ( 7 | Answer, 8 | Course, 9 | Homework, 10 | Project, 11 | ProjectSubmission, 12 | ) 13 | 14 | from django.forms.models import model_to_dict 15 | 16 | 17 | @token_required 18 | def homework_data_view(request, course_slug: str, homework_slug: str): 19 | course = get_object_or_404(Course, slug=course_slug) 20 | 21 | homework = get_object_or_404( 22 | Homework, course=course, slug=homework_slug 23 | ) 24 | 25 | answers_prefetch = Prefetch( 26 | "answer_set", queryset=Answer.objects.all() 27 | ) 28 | submissions = homework.submission_set.prefetch_related( 29 | answers_prefetch 30 | ).all() 31 | 32 | course_data = model_to_dict( 33 | course, exclude=["students", "first_homework_scored"] 34 | ) 35 | 36 | submission_data = [] 37 | for submission in submissions: 38 | submission_dict = { 39 | "student_id": submission.student_id, 40 | "homework_link": submission.homework_link, 41 | "learning_in_public_links": submission.learning_in_public_links, 42 | "time_spent_lectures": submission.time_spent_lectures, 43 | "time_spent_homework": submission.time_spent_homework, 44 | "problems_comments": submission.problems_comments, 45 | "faq_contribution": submission.faq_contribution, 46 | "questions_score": submission.questions_score, 47 | "faq_score": submission.faq_score, 48 | "learning_in_public_score": submission.learning_in_public_score, 49 | "total_score": submission.total_score, 50 | "answers": list( 51 | submission.answer_set.values( 52 | "question_id", "answer_text", "is_correct" 53 | ) 54 | ), 55 | } 56 | submission_data.append(submission_dict) 57 | 58 | result = { 59 | "course": course_data, 60 | "homework": model_to_dict(homework), 61 | "submissions": submission_data, 62 | } 63 | 64 | return JsonResponse(result) 65 | 66 | 67 | @token_required 68 | def project_data_view(request, course_slug: str, project_slug: str): 69 | course = get_object_or_404(Course, slug=course_slug) 70 | project = get_object_or_404( 71 | Project, course=course, slug=project_slug 72 | ) 73 | 74 | submissions = ( 75 | ProjectSubmission.objects.filter(project=project) 76 | .prefetch_related("student", "enrollment") 77 | .all() 78 | ) 79 | 80 | course_data = model_to_dict( 81 | course, exclude=["students", "first_homework_scored"] 82 | ) 83 | 84 | submission_data = [] 85 | for submission in submissions: 86 | submission_dict = { 87 | "student_id": submission.student_id, 88 | "student_email": submission.student.email, 89 | "github_link": submission.github_link, 90 | "commit_id": submission.commit_id, 91 | "learning_in_public_links": submission.learning_in_public_links, 92 | "faq_contribution": submission.faq_contribution, 93 | "time_spent": submission.time_spent, 94 | "problems_comments": submission.problems_comments, 95 | "project_score": submission.project_score, 96 | "project_faq_score": submission.project_faq_score, 97 | "project_learning_in_public_score": submission.project_learning_in_public_score, 98 | "peer_review_score": submission.peer_review_score, 99 | "peer_review_learning_in_public_score": submission.peer_review_learning_in_public_score, 100 | "total_score": submission.total_score, 101 | "reviewed_enough_peers": submission.reviewed_enough_peers, 102 | "passed": submission.passed, 103 | } 104 | submission_data.append(submission_dict) 105 | 106 | # Compile the result 107 | result = { 108 | "course": course_data, 109 | "project": model_to_dict(project), 110 | "submissions": submission_data, 111 | } 112 | 113 | return JsonResponse(result) 114 | -------------------------------------------------------------------------------- /courses/views/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from courses.models import Answer, Enrollment 4 | 5 | 6 | class AnswerForm(forms.ModelForm): 7 | class Meta: 8 | model = Answer 9 | fields = ["answer_text"] 10 | 11 | 12 | class EnrollmentForm(forms.ModelForm): 13 | class Meta: 14 | model = Enrollment 15 | fields = [ 16 | "display_name", 17 | "certificate_name", 18 | "github_url", 19 | "linkedin_url", 20 | "personal_website_url", 21 | "about_me", 22 | ] 23 | widgets = { 24 | "display_name": forms.TextInput( 25 | attrs={"class": "form-control"} 26 | ), 27 | "certificate_name": forms.TextInput( 28 | attrs={"class": "form-control"} 29 | ), 30 | "github_url": forms.TextInput( 31 | attrs={"class": "form-control", "optional": True} 32 | ), 33 | "linkedin_url": forms.TextInput( 34 | attrs={"class": "form-control", "optional": True} 35 | ), 36 | "personal_website_url": forms.TextInput( 37 | attrs={"class": "form-control", "optional": True} 38 | ), 39 | "about_me": forms.Textarea( 40 | attrs={ 41 | "rows": 3, 42 | "style": "height: 100px;", 43 | "class": "form-control", 44 | "optional": True, 45 | } 46 | ), 47 | } 48 | 49 | def is_valid(self): 50 | valid = super().is_valid() 51 | if not valid: 52 | for field in self.fields: 53 | if field in self.errors: 54 | attrs = self.fields[field].widget.attrs 55 | class_name = attrs.get("class", "") 56 | attrs["class"] = class_name + " is-invalid" 57 | return valid 58 | -------------------------------------------------------------------------------- /db/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataTalksClub/course-management-platform/623299630d92b1c62638a7f46ba7dd1f5b302454/db/.gitkeep -------------------------------------------------------------------------------- /deploy/deploy_dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Navigate to the script's directory 6 | cd "$(dirname "$0")" 7 | 8 | TAG=$1 9 | ENV=$2 10 | 11 | # Check if tag is provided 12 | if [ -z "$TAG" ]; then 13 | echo "Error: No tag provided." 14 | echo "Usage: ./deploy_dev.sh " 15 | exit 1 16 | fi 17 | 18 | if [ -z "$ENV" ]; then 19 | ENV="dev" 20 | fi 21 | 22 | DEV_TASK_DEF="course-management-${ENV}" 23 | echo "Deploying ${DEV_TASK_DEF}-${TAG} to ${ENV} environment" 24 | 25 | FILE_IN="${DEV_TASK_DEF}-${TAG}.json" 26 | FILE_OUT="updated_${DEV_TASK_DEF}-${TAG}.json" 27 | 28 | 29 | echo "writing task definition to ${FILE_IN}" 30 | aws ecs describe-task-definition \ 31 | --task-definition ${DEV_TASK_DEF} \ 32 | > ${FILE_IN} 33 | 34 | 35 | echo "writing updated task definition to ${FILE_OUT}" 36 | python update_task_def.py ${FILE_IN} ${TAG} ${FILE_OUT} 37 | 38 | # Register new task definition (Assuming AWS CLI and proper permissions are set up) 39 | aws ecs register-task-definition \ 40 | --cli-input-json file://${FILE_OUT} \ 41 | > /dev/null 42 | 43 | # Update ECS service (replace 'my-cluster' with your actual ECS cluster name) 44 | aws ecs update-service \ 45 | --cluster course-management-cluster \ 46 | --service course-management-${ENV} \ 47 | --task-definition $DEV_TASK_DEF \ 48 | > /dev/null 49 | 50 | # Clean up JSON files 51 | rm -f ${FILE_IN} ${FILE_OUT} 52 | 53 | echo "${ENV} deployment completed successfully." 54 | -------------------------------------------------------------------------------- /deploy/deploy_prod.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/bash 3 | 4 | set -e 5 | 6 | # Navigate to the script's directory 7 | cd "$(dirname "$0")" 8 | 9 | 10 | DEV_TAG=$1 11 | 12 | if [ -z "$DEV_TAG" ]; then 13 | echo "No tag provided. Fetching tag from the dev environment." 14 | DEV_TASK_DEF="course-management-dev" 15 | 16 | FILE_IN="${DEV_TASK_DEF}-current.json" 17 | 18 | echo "writing task definition to ${FILE_IN}" 19 | aws ecs describe-task-definition \ 20 | --task-definition ${DEV_TASK_DEF} \ 21 | > ${FILE_IN} 22 | 23 | DEV_TAG=$( 24 | jq '.taskDefinition.containerDefinitions[].environment[] | select(.name == "VERSION").value' -r ${FILE_IN} 25 | ) 26 | 27 | rm -f ${FILE_IN} 28 | fi 29 | 30 | 31 | echo "deploying ${DEV_TAG} to prod" 32 | 33 | # Check if running outside GitHub Actions and need confirmation 34 | if [ -z "${GITHUB_ACTIONS}" ] && [ "${CONFIRM_DEPLOY}" != "true" ]; then 35 | read -p "Are you sure you want to deploy to production? (y/N) " -n 1 -r 36 | echo 37 | if [[ $REPLY =~ ^[Yy]$ ]]; then 38 | CONFIRM_DEPLOY="true" 39 | fi 40 | fi 41 | 42 | if [ "${CONFIRM_DEPLOY}" != "true" ]; then 43 | echo "Exiting without deploying." 44 | exit 1 45 | fi 46 | 47 | 48 | bash deploy_dev.sh ${DEV_TAG} prod -------------------------------------------------------------------------------- /deploy/update_task_def.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | 4 | def update_task_definition(input_file, new_tag, output_file): 5 | """ 6 | Update the image tag in the task definition and save to a new file. 7 | 8 | :param input_file: Path to the file containing the task definition JSON. 9 | :param new_tag: New tag to update the image with. 10 | :param output_file: Path to the file where the updated task definition will be saved. 11 | """ 12 | 13 | print(f"Updating task definition from {input_file} with new tag {new_tag} and saving to {output_file}") 14 | 15 | try: 16 | with open(input_file, 'r') as file: 17 | task_def = json.load(file)["taskDefinition"] 18 | 19 | # Extract and update the container definition 20 | for container_def in task_def["containerDefinitions"]: 21 | if "image" in container_def: 22 | image = container_def["image"] 23 | base_image, _ = image.split(":") 24 | new_image = f"{base_image}:{new_tag}" 25 | container_def["image"] = new_image 26 | 27 | environment = container_def.get("environment", []) 28 | executed = False 29 | for env_var in environment: 30 | if env_var["name"] == "VERSION": 31 | executed = True 32 | env_var["value"] = new_tag 33 | if not executed: 34 | version = {"name": "VERSION", "value": new_tag} 35 | environment.append(version) 36 | 37 | # Remove fields not allowed in register-task-definition 38 | task_def.pop("status", None) 39 | task_def.pop("revision", None) 40 | task_def.pop("taskDefinitionArn", None) 41 | task_def.pop("requiresAttributes", None) 42 | task_def.pop("compatibilities", None) 43 | task_def.pop("registeredAt", None) 44 | task_def.pop("registeredBy", None) 45 | 46 | with open(output_file, 'w') as file: 47 | json.dump(task_def, file, indent=4) 48 | 49 | print(f"Updated task definition saved to {output_file}") 50 | 51 | except FileNotFoundError: 52 | print(f"File not found: {input_file}") 53 | sys.exit(1) 54 | except json.JSONDecodeError: 55 | print("Invalid JSON input.") 56 | sys.exit(1) 57 | except KeyError: 58 | print("Invalid task definition structure.") 59 | sys.exit(1) 60 | 61 | if __name__ == "__main__": 62 | if len(sys.argv) != 4: 63 | print("Usage: python update_task_def.py ") 64 | sys.exit(1) 65 | 66 | argv = sys.argv 67 | update_task_definition(argv[1], argv[2], argv[3]) 68 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Apply database migrations" 4 | python manage.py migrate 5 | 6 | # Check if migrate command was successful 7 | if [ $? -ne 0 ]; then 8 | echo "Failed to apply database migrations." 9 | exit 1 10 | else 11 | echo "Database migrations applied successfully." 12 | fi 13 | 14 | echo "Starting server" 15 | exec "$@" -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "course_management.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /notebooks/copy-homework.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "f78701db", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "IS_LOCAL=True\n", 14 | "['C:\\\\Users\\\\alexe\\\\git\\\\course-management-platform\\\\.venv\\\\Lib\\\\site-packages\\\\ipykernel_launcher.py', '-f', 'C:\\\\Users\\\\alexe\\\\AppData\\\\Roaming\\\\jupyter\\\\runtime\\\\kernel-d0476a49-e77a-4a1f-9edd-54f6f4169085.json']\n", 15 | "Is test: False\n" 16 | ] 17 | } 18 | ], 19 | "source": [ 20 | "import os\n", 21 | "os.chdir('..')\n", 22 | "\n", 23 | "os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"course_management.settings\"\n", 24 | "os.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"true\"\n", 25 | "os.environ[\"IS_LOCAL\"] = \"1\"\n", 26 | "\n", 27 | "import django\n", 28 | "django.setup()" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 2, 34 | "id": "c7af9387-0c73-4b6d-a0db-e49bee7237fd", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "from courses.models import *" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 3, 44 | "id": "bfc4ba99-14bc-44ec-8a23-d3d3b4b0c3cd", 45 | "metadata": {}, 46 | "outputs": [ 47 | { 48 | "data": { 49 | "text/plain": [ 50 | "" 51 | ] 52 | }, 53 | "execution_count": 3, 54 | "metadata": {}, 55 | "output_type": "execute_result" 56 | } 57 | ], 58 | "source": [ 59 | "old_course = Course.objects.get(id=3)\n", 60 | "old_course" 61 | ] 62 | }, 63 | { 64 | "cell_type": "code", 65 | "execution_count": 4, 66 | "id": "7d5c51cd-d4c4-4164-8e07-bc863696245b", 67 | "metadata": {}, 68 | "outputs": [ 69 | { 70 | "data": { 71 | "text/plain": [ 72 | "" 73 | ] 74 | }, 75 | "execution_count": 4, 76 | "metadata": {}, 77 | "output_type": "execute_result" 78 | } 79 | ], 80 | "source": [ 81 | "new_course = Course.objects.get(id=7)\n", 82 | "new_course" 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": 29, 88 | "id": "acfc1fe7-380e-4313-ad31-1043c748a114", 89 | "metadata": {}, 90 | "outputs": [ 91 | { 92 | "data": { 93 | "text/plain": [ 94 | "" 95 | ] 96 | }, 97 | "execution_count": 29, 98 | "metadata": {}, 99 | "output_type": "execute_result" 100 | } 101 | ], 102 | "source": [ 103 | "homework = Homework.objects.get(id=17)\n", 104 | "homework" 105 | ] 106 | }, 107 | { 108 | "cell_type": "code", 109 | "execution_count": 30, 110 | "id": "05bc1ee5-7d7b-4a3a-8d45-fe748be492aa", 111 | "metadata": {}, 112 | "outputs": [], 113 | "source": [ 114 | "new_homework = Homework.objects.create(\n", 115 | " slug=homework.slug,\n", 116 | " course=new_course,\n", 117 | " title=homework.title,\n", 118 | " description=homework.description,\n", 119 | " due_date=homework.due_date,\n", 120 | " learning_in_public_cap=homework.learning_in_public_cap,\n", 121 | " homework_url_field=homework.homework_url_field,\n", 122 | " time_spent_lectures_field=homework.time_spent_lectures_field,\n", 123 | " time_spent_homework_field=homework.time_spent_homework_field,\n", 124 | " faq_contribution_field=homework.faq_contribution_field,\n", 125 | " state=HomeworkState.CLOSED.value\n", 126 | ")\n", 127 | "\n", 128 | "for q in homework.question_set.all():\n", 129 | " Question.objects.create(\n", 130 | " homework=new_homework,\n", 131 | " text=q.text,\n", 132 | " question_type=q.question_type,\n", 133 | " answer_type=q.answer_type,\n", 134 | " possible_answers=q.possible_answers,\n", 135 | " correct_answer=q.correct_answer,\n", 136 | " scores_for_correct_answer=q.scores_for_correct_answer,\n", 137 | " )" 138 | ] 139 | }, 140 | { 141 | "cell_type": "code", 142 | "execution_count": 24, 143 | "id": "f8958cc6-55ef-46c1-a279-c42fd023455c", 144 | "metadata": {}, 145 | "outputs": [], 146 | "source": [] 147 | }, 148 | { 149 | "cell_type": "code", 150 | "execution_count": null, 151 | "id": "189f1499-0264-49f4-8cd2-6dbefc47e0d5", 152 | "metadata": {}, 153 | "outputs": [], 154 | "source": [] 155 | } 156 | ], 157 | "metadata": { 158 | "kernelspec": { 159 | "display_name": "Python 3 (ipykernel)", 160 | "language": "python", 161 | "name": "python3" 162 | }, 163 | "language_info": { 164 | "codemirror_mode": { 165 | "name": "ipython", 166 | "version": 3 167 | }, 168 | "file_extension": ".py", 169 | "mimetype": "text/x-python", 170 | "name": "python", 171 | "nbconvert_exporter": "python", 172 | "pygments_lexer": "ipython3", 173 | "version": "3.12.3" 174 | } 175 | }, 176 | "nbformat": 4, 177 | "nbformat_minor": 5 178 | } 179 | -------------------------------------------------------------------------------- /notebooks/homework-submissions.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "f78701db", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "IS_LOCAL=True\n", 14 | "['C:\\\\Users\\\\alexe\\\\.virtualenvs\\\\course-management-platform-wiAsnpQu\\\\Lib\\\\site-packages\\\\ipykernel_launcher.py', '-f', 'C:\\\\Users\\\\alexe\\\\AppData\\\\Roaming\\\\jupyter\\\\runtime\\\\kernel-1dc6ab01-664c-4850-b4d6-370532d03de1.json']\n", 15 | "Is test: False\n" 16 | ] 17 | } 18 | ], 19 | "source": [ 20 | "import os\n", 21 | "os.chdir('..')\n", 22 | "\n", 23 | "os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"course_management.settings\"\n", 24 | "os.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"true\"\n", 25 | "os.environ[\"IS_LOCAL\"] = \"1\"\n", 26 | "\n", 27 | "import django\n", 28 | "django.setup()" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 2, 34 | "id": "c7af9387-0c73-4b6d-a0db-e49bee7237fd", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "from courses.models import *" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 5, 44 | "id": "bfc4ba99-14bc-44ec-8a23-d3d3b4b0c3cd", 45 | "metadata": {}, 46 | "outputs": [ 47 | { 48 | "data": { 49 | "text/plain": [ 50 | "" 51 | ] 52 | }, 53 | "execution_count": 5, 54 | "metadata": {}, 55 | "output_type": "execute_result" 56 | } 57 | ], 58 | "source": [ 59 | "course = Course.objects.get(id=6)\n", 60 | "course" 61 | ] 62 | }, 63 | { 64 | "cell_type": "code", 65 | "execution_count": 6, 66 | "id": "bf067f4c-6a96-4676-8792-3a4c5502a4a8", 67 | "metadata": {}, 68 | "outputs": [], 69 | "source": [ 70 | "homework = Homework.objects.get(course=course, slug='hw1')" 71 | ] 72 | }, 73 | { 74 | "cell_type": "code", 75 | "execution_count": 7, 76 | "id": "013370ef-225e-4794-9718-6d6027c667e6", 77 | "metadata": {}, 78 | "outputs": [ 79 | { 80 | "data": { 81 | "text/plain": [ 82 | "" 83 | ] 84 | }, 85 | "execution_count": 7, 86 | "metadata": {}, 87 | "output_type": "execute_result" 88 | } 89 | ], 90 | "source": [ 91 | "homework" 92 | ] 93 | }, 94 | { 95 | "cell_type": "code", 96 | "execution_count": 8, 97 | "id": "8981e4d1-c009-4c8c-aff7-ce8a32d484b0", 98 | "metadata": {}, 99 | "outputs": [], 100 | "source": [ 101 | "submissions = Submission.objects.filter(homework=homework)" 102 | ] 103 | }, 104 | { 105 | "cell_type": "code", 106 | "execution_count": 20, 107 | "id": "c2ad55a4-e7b3-4747-aba0-2908b939bd34", 108 | "metadata": {}, 109 | "outputs": [], 110 | "source": [ 111 | "answers = Answer.objects.filter(submission__in=submissions)" 112 | ] 113 | }, 114 | { 115 | "cell_type": "code", 116 | "execution_count": 25, 117 | "id": "af1d87ba-99ee-4860-80db-dedf049051d4", 118 | "metadata": {}, 119 | "outputs": [ 120 | { 121 | "data": { 122 | "text/plain": [ 123 | "7693" 124 | ] 125 | }, 126 | "execution_count": 25, 127 | "metadata": {}, 128 | "output_type": "execute_result" 129 | } 130 | ], 131 | "source": [ 132 | "len(answers)" 133 | ] 134 | }, 135 | { 136 | "cell_type": "code", 137 | "execution_count": 38, 138 | "id": "7e78ad09-6033-48cb-9419-a2291968ae62", 139 | "metadata": {}, 140 | "outputs": [], 141 | "source": [ 142 | "errors = []\n", 143 | "\n", 144 | "for a in answers:\n", 145 | " answer_text = a.answer_text \n", 146 | " if not answer_text:\n", 147 | " continue\n", 148 | " try:\n", 149 | " int(answer_text)\n", 150 | " except:\n", 151 | " errors.append(a)" 152 | ] 153 | }, 154 | { 155 | "cell_type": "code", 156 | "execution_count": 36, 157 | "id": "85998432-7830-4628-ac4d-b805ae40c886", 158 | "metadata": {}, 159 | "outputs": [], 160 | "source": [ 161 | "errors[0].answer_text = '4'\n", 162 | "errors[0].save()" 163 | ] 164 | }, 165 | { 166 | "cell_type": "code", 167 | "execution_count": 37, 168 | "id": "5554bf46-5277-4856-b090-315e27f58f49", 169 | "metadata": {}, 170 | "outputs": [], 171 | "source": [ 172 | "errors[1].answer_text = '4'\n", 173 | "errors[1].save()" 174 | ] 175 | }, 176 | { 177 | "cell_type": "code", 178 | "execution_count": null, 179 | "id": "1124c3f9-e380-4a11-925a-640ccf05f7db", 180 | "metadata": {}, 181 | "outputs": [], 182 | "source": [] 183 | } 184 | ], 185 | "metadata": { 186 | "kernelspec": { 187 | "display_name": "Python 3 (ipykernel)", 188 | "language": "python", 189 | "name": "python3" 190 | }, 191 | "language_info": { 192 | "codemirror_mode": { 193 | "name": "ipython", 194 | "version": 3 195 | }, 196 | "file_extension": ".py", 197 | "mimetype": "text/x-python", 198 | "name": "python", 199 | "nbconvert_exporter": "python", 200 | "pygments_lexer": "ipython3", 201 | "version": "3.12.3" 202 | } 203 | }, 204 | "nbformat": 4, 205 | "nbformat_minor": 5 206 | } 207 | -------------------------------------------------------------------------------- /notebooks/merge_submissions.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 2, 6 | "id": "f78701db", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "IS_LOCAL=True\n", 14 | "['C:\\\\Users\\\\alexe\\\\git\\\\course-management-platform\\\\.venv\\\\lib\\\\site-packages\\\\ipykernel_launcher.py', '-f', 'C:\\\\Users\\\\alexe\\\\AppData\\\\Roaming\\\\jupyter\\\\runtime\\\\kernel-2515941b-7db2-4097-ac61-80775b1acc9f.json']\n", 15 | "Is test: False\n" 16 | ] 17 | } 18 | ], 19 | "source": [ 20 | "import os\n", 21 | "os.chdir('..')\n", 22 | "\n", 23 | "os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"course_management.settings\"\n", 24 | "os.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"true\"\n", 25 | "os.environ[\"IS_LOCAL\"] = \"1\"\n", 26 | "\n", 27 | "import django\n", 28 | "django.setup()" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 3, 34 | "id": "c7af9387-0c73-4b6d-a0db-e49bee7237fd", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "from courses.models import *" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 4, 44 | "id": "a9d73e4f-6889-4237-a262-9ee655c45999", 45 | "metadata": {}, 46 | "outputs": [ 47 | { 48 | "data": { 49 | "text/plain": [ 50 | "" 51 | ] 52 | }, 53 | "execution_count": 4, 54 | "metadata": {}, 55 | "output_type": "execute_result" 56 | } 57 | ], 58 | "source": [ 59 | "course = Course.objects.get(id=5)\n", 60 | "course" 61 | ] 62 | }, 63 | { 64 | "cell_type": "code", 65 | "execution_count": 5, 66 | "id": "bfc4ba99-14bc-44ec-8a23-d3d3b4b0c3cd", 67 | "metadata": {}, 68 | "outputs": [], 69 | "source": [ 70 | "email_main = \"x\"\n", 71 | "email_other = \"x\"\n", 72 | "\n", 73 | "user_main = User.objects.get(email=email_main)\n", 74 | "enrollment_main = Enrollment.objects.get(course=course, student=user_main)\n", 75 | "\n", 76 | "user_other = User.objects.get(email=email_other)\n", 77 | "enrollment_other = Enrollment.objects.get(course=course, student=user_other)" 78 | ] 79 | }, 80 | { 81 | "cell_type": "code", 82 | "execution_count": 6, 83 | "id": "c2821302-5984-4442-86ae-77441de94318", 84 | "metadata": {}, 85 | "outputs": [ 86 | { 87 | "name": "stdout", 88 | "output_type": "stream", 89 | "text": [ 90 | "5230 0\n", 91 | "4751 23\n" 92 | ] 93 | } 94 | ], 95 | "source": [ 96 | "print(enrollment_main.id, enrollment_main.total_score)\n", 97 | "print(enrollment_other.id, enrollment_other.total_score)" 98 | ] 99 | }, 100 | { 101 | "cell_type": "code", 102 | "execution_count": 7, 103 | "id": "4cf3d02a-c66a-42dd-8f7e-91ee592deae9", 104 | "metadata": {}, 105 | "outputs": [ 106 | { 107 | "name": "stdout", 108 | "output_type": "stream", 109 | "text": [ 110 | "updated 11849\n", 111 | "updated 12218\n", 112 | "updated 11425\n", 113 | "updated 10948\n" 114 | ] 115 | } 116 | ], 117 | "source": [ 118 | "other_submissions = Submission.objects.filter(enrollment=enrollment_other)\n", 119 | "\n", 120 | "for submission in other_submissions:\n", 121 | " submission.student = user_main\n", 122 | " submission.enrollment = enrollment_main\n", 123 | " submission.save()\n", 124 | "\n", 125 | " print(f'updated {submission.id}')" 126 | ] 127 | }, 128 | { 129 | "cell_type": "code", 130 | "execution_count": 18, 131 | "id": "0dc53fe0-f922-4936-9e27-ff4d30b56d2e", 132 | "metadata": {}, 133 | "outputs": [], 134 | "source": [] 135 | }, 136 | { 137 | "cell_type": "code", 138 | "execution_count": null, 139 | "id": "416b6e84-5ae1-418d-a5ef-fc1a08f2b1d6", 140 | "metadata": {}, 141 | "outputs": [], 142 | "source": [] 143 | } 144 | ], 145 | "metadata": { 146 | "kernelspec": { 147 | "display_name": "Python 3 (ipykernel)", 148 | "language": "python", 149 | "name": "python3" 150 | }, 151 | "language_info": { 152 | "codemirror_mode": { 153 | "name": "ipython", 154 | "version": 3 155 | }, 156 | "file_extension": ".py", 157 | "mimetype": "text/x-python", 158 | "name": "python", 159 | "nbconvert_exporter": "python", 160 | "pygments_lexer": "ipython3", 161 | "version": "3.9.13" 162 | } 163 | }, 164 | "nbformat": 4, 165 | "nbformat_minor": 5 166 | } 167 | -------------------------------------------------------------------------------- /notebooks/project-review-delete.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "f78701db", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "IS_LOCAL=True\n", 14 | "['C:\\\\Users\\\\alexe\\\\git\\\\course-management-platform\\\\.venv\\\\lib\\\\site-packages\\\\ipykernel_launcher.py', '-f', 'C:\\\\Users\\\\alexe\\\\AppData\\\\Roaming\\\\jupyter\\\\runtime\\\\kernel-d5d3620b-fc91-4661-a648-5cb9f015fb9a.json']\n", 15 | "Is test: False\n" 16 | ] 17 | } 18 | ], 19 | "source": [ 20 | "import os\n", 21 | "os.chdir('..')\n", 22 | "\n", 23 | "os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"course_management.settings\"\n", 24 | "os.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"true\"\n", 25 | "os.environ[\"IS_LOCAL\"] = \"1\"\n", 26 | "\n", 27 | "import django\n", 28 | "django.setup()" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 2, 34 | "id": "c7af9387-0c73-4b6d-a0db-e49bee7237fd", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "from courses.models import *" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 5, 44 | "id": "36039243-82e6-4c89-bdbe-e5120a8fac98", 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [ 48 | "project = Project.objects.get(id=9)" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 6, 54 | "id": "271342ba-a36d-4623-8d61-1424eb8efb99", 55 | "metadata": {}, 56 | "outputs": [], 57 | "source": [ 58 | "reviews = PeerReview.objects.filter(submission_under_evaluation__project=project)" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": 9, 64 | "id": "a40e69c5-c11c-4a45-8f69-fcc58c450ad4", 65 | "metadata": {}, 66 | "outputs": [ 67 | { 68 | "data": { 69 | "text/plain": [ 70 | "(577, {'courses.PeerReview': 577})" 71 | ] 72 | }, 73 | "execution_count": 9, 74 | "metadata": {}, 75 | "output_type": "execute_result" 76 | } 77 | ], 78 | "source": [ 79 | "reviews.delete()" 80 | ] 81 | }, 82 | { 83 | "cell_type": "code", 84 | "execution_count": 10, 85 | "id": "6c901d07-47c5-41fd-b7d0-93843db38e7c", 86 | "metadata": {}, 87 | "outputs": [ 88 | { 89 | "data": { 90 | "text/plain": [ 91 | "0" 92 | ] 93 | }, 94 | "execution_count": 10, 95 | "metadata": {}, 96 | "output_type": "execute_result" 97 | } 98 | ], 99 | "source": [ 100 | "len(reviews)" 101 | ] 102 | }, 103 | { 104 | "cell_type": "code", 105 | "execution_count": null, 106 | "id": "6ba3e481-ac95-4376-b67f-3920bb8a9210", 107 | "metadata": {}, 108 | "outputs": [], 109 | "source": [] 110 | } 111 | ], 112 | "metadata": { 113 | "kernelspec": { 114 | "display_name": "Python 3 (ipykernel)", 115 | "language": "python", 116 | "name": "python3" 117 | }, 118 | "language_info": { 119 | "codemirror_mode": { 120 | "name": "ipython", 121 | "version": 3 122 | }, 123 | "file_extension": ".py", 124 | "mimetype": "text/x-python", 125 | "name": "python", 126 | "nbconvert_exporter": "python", 127 | "pygments_lexer": "ipython3", 128 | "version": "3.9.13" 129 | } 130 | }, 131 | "nbformat": 4, 132 | "nbformat_minor": 5 133 | } 134 | -------------------------------------------------------------------------------- /notebooks/project-submission.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "f78701db", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "IS_LOCAL=True\n", 14 | "['C:\\\\Users\\\\alexe\\\\git\\\\course-management-platform\\\\.venv\\\\lib\\\\site-packages\\\\ipykernel_launcher.py', '-f', 'C:\\\\Users\\\\alexe\\\\AppData\\\\Roaming\\\\jupyter\\\\runtime\\\\kernel-b41fb298-c4f0-4d8a-9ccd-469bacae0dda.json']\n", 15 | "Is test: False\n" 16 | ] 17 | } 18 | ], 19 | "source": [ 20 | "import os\n", 21 | "os.chdir('..')\n", 22 | "\n", 23 | "os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"course_management.settings\"\n", 24 | "os.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"true\"\n", 25 | "os.environ[\"IS_LOCAL\"] = \"1\"\n", 26 | "\n", 27 | "import django\n", 28 | "django.setup()" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 2, 34 | "id": "c7af9387-0c73-4b6d-a0db-e49bee7237fd", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "from courses.models import *" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 6, 44 | "id": "bfc4ba99-14bc-44ec-8a23-d3d3b4b0c3cd", 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [ 48 | "pr = PeerReview.objects.get(id=1748)" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 10, 54 | "id": "588d0dfb-cfb2-4108-8d9c-668e30e1f4d8", 55 | "metadata": {}, 56 | "outputs": [], 57 | "source": [ 58 | "submission = pr.submission_under_evaluation" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": 13, 64 | "id": "29f3a207-e75c-4954-aba5-9d1dd7d55c26", 65 | "metadata": {}, 66 | "outputs": [ 67 | { 68 | "data": { 69 | "text/plain": [ 70 | "''" 71 | ] 72 | }, 73 | "execution_count": 13, 74 | "metadata": {}, 75 | "output_type": "execute_result" 76 | } 77 | ], 78 | "source": [ 79 | "submission.github_link" 80 | ] 81 | }, 82 | { 83 | "cell_type": "code", 84 | "execution_count": 14, 85 | "id": "0cd2cefe-dc45-4aca-aa7b-fe12dc706b49", 86 | "metadata": {}, 87 | "outputs": [ 88 | { 89 | "data": { 90 | "text/plain": [ 91 | "''" 92 | ] 93 | }, 94 | "execution_count": 14, 95 | "metadata": {}, 96 | "output_type": "execute_result" 97 | } 98 | ], 99 | "source": [ 100 | "submission.commit_id" 101 | ] 102 | }, 103 | { 104 | "cell_type": "code", 105 | "execution_count": 18, 106 | "id": "8ca3af64-deb0-41cf-ab9f-61d8babd4cfd", 107 | "metadata": {}, 108 | "outputs": [], 109 | "source": [ 110 | "submission.learning_in_public_links" 111 | ] 112 | }, 113 | { 114 | "cell_type": "code", 115 | "execution_count": null, 116 | "id": "8da09f96-b0c3-446b-9c45-daf0d0cea267", 117 | "metadata": {}, 118 | "outputs": [], 119 | "source": [] 120 | } 121 | ], 122 | "metadata": { 123 | "kernelspec": { 124 | "display_name": "Python 3 (ipykernel)", 125 | "language": "python", 126 | "name": "python3" 127 | }, 128 | "language_info": { 129 | "codemirror_mode": { 130 | "name": "ipython", 131 | "version": 3 132 | }, 133 | "file_extension": ".py", 134 | "mimetype": "text/x-python", 135 | "name": "python", 136 | "nbconvert_exporter": "python", 137 | "pygments_lexer": "ipython3", 138 | "version": "3.9.13" 139 | } 140 | }, 141 | "nbformat": 4, 142 | "nbformat_minor": 5 143 | } 144 | -------------------------------------------------------------------------------- /notebooks/scoring-speedup.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "f78701db", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "IS_LOCAL=True\n", 14 | "['C:\\\\Users\\\\alexe\\\\.virtualenvs\\\\course-management-platform-wiAsnpQu\\\\lib\\\\site-packages\\\\ipykernel_launcher.py', '-f', 'C:\\\\Users\\\\alexe\\\\AppData\\\\Roaming\\\\jupyter\\\\runtime\\\\kernel-5ac07f6b-db89-486a-ae8f-916dfbc2b5be.json']\n", 15 | "Is test: False\n" 16 | ] 17 | } 18 | ], 19 | "source": [ 20 | "import os\n", 21 | "os.chdir('..')\n", 22 | "\n", 23 | "os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"course_management.settings\"\n", 24 | "os.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"true\"\n", 25 | "os.environ[\"IS_LOCAL\"] = \"1\"\n", 26 | "\n", 27 | "import django\n", 28 | "django.setup()" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 2, 34 | "id": "d27cf9d1-c1b9-4fd9-a436-4ae8228cafc0", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "from courses.models import *" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 3, 44 | "id": "2a469e57-58fe-49a4-994c-3470dd38cf6d", 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [ 48 | "# homework_id = '1'\n", 49 | "# hw = Homework.objects.get(id=homework_id)\n", 50 | "# hw.is_scored = False\n", 51 | "# hw.save()" 52 | ] 53 | }, 54 | { 55 | "cell_type": "code", 56 | "execution_count": 4, 57 | "id": "65b6df05-ba04-4fb9-9138-3e07dbebcb7a", 58 | "metadata": {}, 59 | "outputs": [], 60 | "source": [ 61 | "from courses import scoring" 62 | ] 63 | }, 64 | { 65 | "cell_type": "code", 66 | "execution_count": null, 67 | "id": "5649e9e3-7df6-4045-bc33-5541fd82c5a4", 68 | "metadata": {}, 69 | "outputs": [], 70 | "source": [ 71 | "scoring.score_homework_submissions('3')" 72 | ] 73 | }, 74 | { 75 | "cell_type": "code", 76 | "execution_count": 15, 77 | "id": "b301c9ff-79b9-4a19-80aa-b581344f0ef2", 78 | "metadata": {}, 79 | "outputs": [], 80 | "source": [ 81 | "s = Submission.objects.get(id=2871)" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": 16, 87 | "id": "8d3cd268-1641-4bb5-ab3c-76b6adf53aea", 88 | "metadata": {}, 89 | "outputs": [], 90 | "source": [ 91 | "def replace_answers_with_indexes(possible_answers, answers, question_id=None):\n", 92 | " possible_answers = [\n", 93 | " answer.strip().lower() for answer in possible_answers\n", 94 | " ]\n", 95 | " answers = answers.lower().strip()\n", 96 | "\n", 97 | " correct_indexes = []\n", 98 | "\n", 99 | " for answer in answers.split(\",\"):\n", 100 | " answer = answer.strip()\n", 101 | " try:\n", 102 | " zero_based_index = possible_answers.index(answer)\n", 103 | " index = zero_based_index + 1\n", 104 | " correct_indexes.append(str(index))\n", 105 | " except ValueError:\n", 106 | " raise\n", 107 | "\n", 108 | " result = \",\".join(correct_indexes)\n", 109 | "\n", 110 | " return result\n" 111 | ] 112 | }, 113 | { 114 | "cell_type": "code", 115 | "execution_count": 17, 116 | "id": "042b9ab6-d793-4d6a-bd8e-53b053f7c4be", 117 | "metadata": { 118 | "scrolled": true 119 | }, 120 | "outputs": [ 121 | { 122 | "name": "stdout", 123 | "output_type": "stream", 124 | "text": [ 125 | "18569\n", 126 | "8.382\n", 127 | "{1}\n", 128 | "['10.234', '7.892', '8.382', '9.123']\n", 129 | "updated answer 3\n", 130 | "\n", 131 | "18570\n", 132 | "3.605\n", 133 | "{1}\n", 134 | "['4.236', '3.605', '2.345', '5.678']\n", 135 | "updated answer 2\n", 136 | "\n", 137 | "18571\n", 138 | "365\n", 139 | "{1}\n", 140 | "['353', '365', '378', '390']\n", 141 | "updated answer 2\n", 142 | "\n", 143 | "18572\n", 144 | "266\n", 145 | "{2}\n", 146 | "['215', '266', '241', '258']\n", 147 | "updated answer 2\n", 148 | "\n" 149 | ] 150 | } 151 | ], 152 | "source": [ 153 | "for answer in s.answer_set.all():\n", 154 | " question = answer.question\n", 155 | "\n", 156 | " print(answer.id)\n", 157 | " print(answer.answer_text)\n", 158 | " print(question.get_correct_answer_indices())\n", 159 | " print(question.get_possible_answers())\n", 160 | "\n", 161 | " possible_answers = question.get_possible_answers()\n", 162 | "\n", 163 | " updated_answer = replace_answers_with_indexes(\n", 164 | " possible_answers, answer.answer_text, question.id\n", 165 | " )\n", 166 | "\n", 167 | " print('updated answer', updated_answer)\n", 168 | " answer.answer_text = updated_answer\n", 169 | " answer.save()\n", 170 | " print()" 171 | ] 172 | }, 173 | { 174 | "cell_type": "code", 175 | "execution_count": null, 176 | "id": "3ea0a1a7-b28c-4d67-8ece-5ee8cee737ef", 177 | "metadata": {}, 178 | "outputs": [], 179 | "source": [] 180 | } 181 | ], 182 | "metadata": { 183 | "kernelspec": { 184 | "display_name": "Python 3 (ipykernel)", 185 | "language": "python", 186 | "name": "python3" 187 | }, 188 | "language_info": { 189 | "codemirror_mode": { 190 | "name": "ipython", 191 | "version": 3 192 | }, 193 | "file_extension": ".py", 194 | "mimetype": "text/x-python", 195 | "name": "python", 196 | "nbconvert_exporter": "python", 197 | "pygments_lexer": "ipython3", 198 | "version": "3.9.13" 199 | } 200 | }, 201 | "nbformat": 4, 202 | "nbformat_minor": 5 203 | } 204 | -------------------------------------------------------------------------------- /notebooks/starter.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "f78701db", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "IS_LOCAL=True\n", 14 | "['C:\\\\Users\\\\alexe\\\\.virtualenvs\\\\course-management-platform-wiAsnpQu\\\\lib\\\\site-packages\\\\ipykernel_launcher.py', '-f', 'C:\\\\Users\\\\alexe\\\\AppData\\\\Roaming\\\\jupyter\\\\runtime\\\\kernel-63a30ab0-b622-4166-bafc-127e5f84f91d.json']\n", 15 | "Is test: False\n" 16 | ] 17 | } 18 | ], 19 | "source": [ 20 | "import os\n", 21 | "os.chdir('..')\n", 22 | "\n", 23 | "os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"course_management.settings\"\n", 24 | "os.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"true\"\n", 25 | "os.environ[\"IS_LOCAL\"] = \"1\"\n", 26 | "\n", 27 | "import django\n", 28 | "django.setup()" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 2, 34 | "id": "c7af9387-0c73-4b6d-a0db-e49bee7237fd", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "from courses.models import *" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": null, 44 | "id": "bfc4ba99-14bc-44ec-8a23-d3d3b4b0c3cd", 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [] 48 | } 49 | ], 50 | "metadata": { 51 | "kernelspec": { 52 | "display_name": "Python 3 (ipykernel)", 53 | "language": "python", 54 | "name": "python3" 55 | }, 56 | "language_info": { 57 | "codemirror_mode": { 58 | "name": "ipython", 59 | "version": 3 60 | }, 61 | "file_extension": ".py", 62 | "mimetype": "text/x-python", 63 | "name": "python", 64 | "nbconvert_exporter": "python", 65 | "pygments_lexer": "ipython3", 66 | "version": "3.9.13" 67 | } 68 | }, 69 | "nbformat": 4, 70 | "nbformat_minor": 5 71 | } 72 | -------------------------------------------------------------------------------- /notebooks/submitted-projects.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "f78701db", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "IS_LOCAL=True\n", 14 | "['C:\\\\Users\\\\alexe\\\\.virtualenvs\\\\course-management-platform-wiAsnpQu\\\\lib\\\\site-packages\\\\ipykernel_launcher.py', '-f', 'C:\\\\Users\\\\alexe\\\\AppData\\\\Roaming\\\\jupyter\\\\runtime\\\\kernel-817d394a-9cee-4555-a0de-dde7117e5822.json']\n", 15 | "Is test: False\n" 16 | ] 17 | } 18 | ], 19 | "source": [ 20 | "import os\n", 21 | "os.chdir('..')\n", 22 | "\n", 23 | "os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"course_management.settings\"\n", 24 | "os.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"true\"\n", 25 | "os.environ[\"IS_LOCAL\"] = \"1\"\n", 26 | "\n", 27 | "import django\n", 28 | "django.setup()" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 2, 34 | "id": "c7af9387-0c73-4b6d-a0db-e49bee7237fd", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "from courses.models import *" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 3, 44 | "id": "17074b53-da07-4739-96ea-4f9ffa32f2b6", 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [ 48 | "from django.forms.models import model_to_dict" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 19, 54 | "id": "bfc4ba99-14bc-44ec-8a23-d3d3b4b0c3cd", 55 | "metadata": {}, 56 | "outputs": [], 57 | "source": [ 58 | "# course = Course.objects.get(id=1)\n", 59 | "project = Project.objects.get(id=2)" 60 | ] 61 | }, 62 | { 63 | "cell_type": "code", 64 | "execution_count": 20, 65 | "id": "c6798c0f-726d-4469-a8ac-f0d257252b30", 66 | "metadata": {}, 67 | "outputs": [], 68 | "source": [ 69 | "submissions = ProjectSubmission.objects.filter(project=project)" 70 | ] 71 | }, 72 | { 73 | "cell_type": "code", 74 | "execution_count": 21, 75 | "id": "df384f6b-9c4f-4abe-879b-9a5787ee4279", 76 | "metadata": {}, 77 | "outputs": [ 78 | { 79 | "data": { 80 | "text/plain": [ 81 | "21" 82 | ] 83 | }, 84 | "execution_count": 21, 85 | "metadata": {}, 86 | "output_type": "execute_result" 87 | } 88 | ], 89 | "source": [ 90 | "len(submissions)" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": 22, 96 | "id": "f5d3fbb7-e86a-4e90-b8c6-da2fd7a640fc", 97 | "metadata": {}, 98 | "outputs": [], 99 | "source": [ 100 | "urls = []\n", 101 | "\n", 102 | "for submission in submissions:\n", 103 | " if submission.student_id == 2:\n", 104 | " continue\n", 105 | " urls.append(submission.github_link)" 106 | ] 107 | }, 108 | { 109 | "cell_type": "code", 110 | "execution_count": 23, 111 | "id": "e3ff1d26-e5f2-4661-8b53-02c364c90760", 112 | "metadata": {}, 113 | "outputs": [], 114 | "source": [ 115 | "from csv import DictWriter" 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": 24, 121 | "id": "7a4f1d65-738d-46fa-a4bd-a52e068dc481", 122 | "metadata": {}, 123 | "outputs": [], 124 | "source": [ 125 | "seen = set()\n", 126 | "\n", 127 | "with open('notebooks/data/project-2-de.csv', 'tw') as f_out:\n", 128 | " writer = DictWriter(f_out, ['project_url'])\n", 129 | " writer.writeheader()\n", 130 | " for url in urls:\n", 131 | " if url in seen:\n", 132 | " continue\n", 133 | " writer.writerow({'project_url': url})\n", 134 | " seen.add(url)" 135 | ] 136 | }, 137 | { 138 | "cell_type": "code", 139 | "execution_count": 25, 140 | "id": "39fc4ce4-a715-40b3-9faa-c8f23a4ddd53", 141 | "metadata": {}, 142 | "outputs": [ 143 | { 144 | "name": "stdout", 145 | "output_type": "stream", 146 | "text": [ 147 | "project_url\n", 148 | "\n", 149 | "https://github.com/rebekamukherjee/citibike-data-pipeline\n", 150 | "\n", 151 | "https://github.com/RoshchinM/de_zoomcamp_2024_UCL_2016-2022\n", 152 | "\n", 153 | "https://github.com/chrisdamba/patent-analytics-pipeline\n", 154 | "\n", 155 | "https://github.com/Abubakrmali2/DE-Movies-Project\n", 156 | "\n", 157 | "https://github.com/muhilhamfajar/youtube-analysis-de-zoomcamp\n", 158 | "\n", 159 | "https://github.com/demapumpum/flight-analytics\n", 160 | "\n", 161 | "https://github.com/nyan222/roman_empire_zoomcamp\n", 162 | "\n", 163 | "https://github.com/rafaelcuperman/de-zoomcamp-project\n", 164 | "\n", 165 | "https://github.com/stellalo/Data-Engineering-Capstone-KEGG-dataset-\n", 166 | "\n" 167 | ] 168 | } 169 | ], 170 | "source": [ 171 | "!head notebooks/data/project-1-de.csv" 172 | ] 173 | }, 174 | { 175 | "cell_type": "code", 176 | "execution_count": null, 177 | "id": "385a4914-6581-4a58-892a-48b52ba2f7c9", 178 | "metadata": {}, 179 | "outputs": [], 180 | "source": [] 181 | } 182 | ], 183 | "metadata": { 184 | "kernelspec": { 185 | "display_name": "Python 3 (ipykernel)", 186 | "language": "python", 187 | "name": "python3" 188 | }, 189 | "language_info": { 190 | "codemirror_mode": { 191 | "name": "ipython", 192 | "version": 3 193 | }, 194 | "file_extension": ".py", 195 | "mimetype": "text/x-python", 196 | "name": "python", 197 | "nbconvert_exporter": "python", 198 | "pygments_lexer": "ipython3", 199 | "version": "3.9.13" 200 | } 201 | }, 202 | "nbformat": 4, 203 | "nbformat_minor": 5 204 | } 205 | -------------------------------------------------------------------------------- /notebooks/time-spent-export.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "f78701db", 7 | "metadata": {}, 8 | "outputs": [ 9 | { 10 | "name": "stdout", 11 | "output_type": "stream", 12 | "text": [ 13 | "IS_LOCAL=True\n", 14 | "['C:\\\\Users\\\\alexe\\\\.virtualenvs\\\\course-management-platform-wiAsnpQu\\\\lib\\\\site-packages\\\\ipykernel_launcher.py', '-f', 'C:\\\\Users\\\\alexe\\\\AppData\\\\Roaming\\\\jupyter\\\\runtime\\\\kernel-8469f5ab-c582-4ff2-ad72-e3d21f72e3e4.json']\n", 15 | "Is test: False\n" 16 | ] 17 | } 18 | ], 19 | "source": [ 20 | "import os\n", 21 | "os.chdir('..')\n", 22 | "\n", 23 | "os.environ[\"DJANGO_SETTINGS_MODULE\"] = \"course_management.settings\"\n", 24 | "os.environ[\"DJANGO_ALLOW_ASYNC_UNSAFE\"] = \"true\"\n", 25 | "os.environ[\"IS_LOCAL\"] = \"1\"\n", 26 | "\n", 27 | "import django\n", 28 | "django.setup()" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 4, 34 | "id": "84cdd741-3586-4cc6-9dc3-7ee2dfe04486", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "from courses.models import (\n", 39 | " Course,\n", 40 | " Enrollment,\n", 41 | " Submission,\n", 42 | " Project,\n", 43 | " ProjectSubmission,\n", 44 | ")" 45 | ] 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": 50, 50 | "id": "d2004021-8457-4844-9dda-cdb57540d60d", 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "from hashlib import sha1\n", 55 | "\n", 56 | "def compute_hash(email):\n", 57 | " return sha1(email.lower().encode('utf-8')).hexdigest()" 58 | ] 59 | }, 60 | { 61 | "cell_type": "code", 62 | "execution_count": 6, 63 | "id": "4a112b85-78e8-4830-9db8-661b838f7cbf", 64 | "metadata": {}, 65 | "outputs": [], 66 | "source": [ 67 | "course = Course.objects.get(id=1" 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": 11, 73 | "id": "74765b50-ab56-439a-9cd8-aa0f23c9d231", 74 | "metadata": {}, 75 | "outputs": [ 76 | { 77 | "data": { 78 | "text/plain": [ 79 | "1287" 80 | ] 81 | }, 82 | "execution_count": 11, 83 | "metadata": {}, 84 | "output_type": "execute_result" 85 | } 86 | ], 87 | "source": [ 88 | "enrollments = Enrollment.objects.filter(course=course)\n", 89 | "enrollments.count()" 90 | ] 91 | }, 92 | { 93 | "cell_type": "code", 94 | "execution_count": 12, 95 | "id": "7856ec6d-c95e-4e2d-abca-e15a896e929e", 96 | "metadata": {}, 97 | "outputs": [], 98 | "source": [ 99 | "submissions = Submission.objects.filter(enrollment__course=course)" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": 36, 105 | "id": "fb46e05e-9454-4946-ba79-655ddbf99a02", 106 | "metadata": {}, 107 | "outputs": [], 108 | "source": [ 109 | "values = submissions.values(\n", 110 | " 'student__email',\n", 111 | " 'homework__title',\n", 112 | " 'time_spent_lectures',\n", 113 | " 'time_spent_homework'\n", 114 | ")" 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": 38, 120 | "id": "da7e2893-7aad-4c82-8223-495ef4d69f0c", 121 | "metadata": {}, 122 | "outputs": [], 123 | "source": [ 124 | "from csv import DictWriter" 125 | ] 126 | }, 127 | { 128 | "cell_type": "code", 129 | "execution_count": 54, 130 | "id": "0abfd3f2-6381-4d00-a89f-3b9d64ee0ff3", 131 | "metadata": {}, 132 | "outputs": [], 133 | "source": [ 134 | "csv_file = open('../zoomcamp-analytics/data/de-zoomcamp-2024/homeworks.csv', 'wt')\n", 135 | "\n", 136 | "columns = ['email', 'homework', 'time_homework', 'time_lectures']\n", 137 | "\n", 138 | "writer = DictWriter(csv_file, columns)\n", 139 | "writer.writeheader()" 140 | ] 141 | }, 142 | { 143 | "cell_type": "code", 144 | "execution_count": 56, 145 | "id": "4a5a60c0-54ad-4769-88f2-255692695d62", 146 | "metadata": {}, 147 | "outputs": [], 148 | "source": [ 149 | "for v in values:\n", 150 | " res = {}\n", 151 | " res['email'] = compute_hash(v['student__email'] + '_salt')\n", 152 | " res['homework'] = v['homework__title']\n", 153 | " res['time_homework'] = v['time_spent_lectures']\n", 154 | " res['time_lectures'] = v['time_spent_homework']\n", 155 | "\n", 156 | " if not res['time_homework'] and not res['time_lectures']:\n", 157 | " continue\n", 158 | "\n", 159 | " writer.writerow(res)" 160 | ] 161 | }, 162 | { 163 | "cell_type": "code", 164 | "execution_count": 57, 165 | "id": "693687f0-5b28-4209-87ae-a5db67620ed6", 166 | "metadata": {}, 167 | "outputs": [], 168 | "source": [ 169 | "csv_file.close()" 170 | ] 171 | } 172 | ], 173 | "metadata": { 174 | "kernelspec": { 175 | "display_name": "Python 3 (ipykernel)", 176 | "language": "python", 177 | "name": "python3" 178 | }, 179 | "language_info": { 180 | "codemirror_mode": { 181 | "name": "ipython", 182 | "version": 3 183 | }, 184 | "file_extension": ".py", 185 | "mimetype": "text/x-python", 186 | "name": "python", 187 | "nbconvert_exporter": "python", 188 | "pygments_lexer": "ipython3", 189 | "version": "3.9.13" 190 | } 191 | }, 192 | "nbformat": 4, 193 | "nbformat_minor": 5 194 | } 195 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 72 3 | 4 | [tool.pytest.ini_options] 5 | DJANGO_SETTINGS_MODULE = "course_management.settings" 6 | python_files = ["test_*.py"] 7 | 8 | testpaths = [ 9 | "courses/tests" 10 | ] -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block title %}Course Management{% endblock %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
    22 |
    23 | 45 |
    46 |
    47 | 48 |
    49 |
    50 | {% block content %} 51 | 52 | {% endblock %} 53 |
    54 |
    55 | 56 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /terraform/Makefile: -------------------------------------------------------------------------------- 1 | plan: 2 | terraform plan -var-file=env.json 3 | 4 | apply: 5 | terraform apply -var-file=env.json -------------------------------------------------------------------------------- /terraform/app.tf: -------------------------------------------------------------------------------- 1 | variable "django_key" { 2 | description = "Django secret key" 3 | } 4 | 5 | variable "certificate_arn" { 6 | description = "Certificate ARN for the ALB" 7 | default = "arn:aws:acm:eu-west-1:387546586013:certificate/1fbf1209-f7cc-4a2e-b565-cc9f9dcc7e86" 8 | } 9 | 10 | variable "dev_tag" { 11 | type = string 12 | default = "20240119-220718" 13 | } 14 | 15 | variable "prod_tag" { 16 | type = string 17 | default = "20240119-220718" 18 | } 19 | 20 | 21 | 22 | # ECR 23 | 24 | resource "aws_ecr_repository" "course_management_ecr_repo" { 25 | name = "course-management" 26 | 27 | image_tag_mutability = "MUTABLE" 28 | 29 | image_scanning_configuration { 30 | scan_on_push = true 31 | } 32 | } 33 | 34 | 35 | # ECS Cluster 36 | 37 | resource "aws_ecs_cluster" "course_management_cluster" { 38 | name = "course-management-cluster" 39 | } 40 | 41 | resource "aws_iam_role" "ecs_execution_role" { 42 | name = "ecs-execution-role" 43 | 44 | assume_role_policy = jsonencode({ 45 | Version = "2012-10-17", 46 | Statement = [ 47 | { 48 | Action = "sts:AssumeRole", 49 | Effect = "Allow", 50 | Principal = { 51 | Service = "ecs-tasks.amazonaws.com" 52 | }, 53 | }, 54 | ], 55 | }) 56 | } 57 | 58 | 59 | resource "aws_iam_role_policy_attachment" "ecs_execution_role_policy_attachment" { 60 | role = aws_iam_role.ecs_execution_role.name 61 | policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" 62 | # permissions: 63 | # "ecr:GetAuthorizationToken", 64 | # "ecr:BatchCheckLayerAvailability", 65 | # "ecr:GetDownloadUrlForLayer", 66 | # "ecr:BatchGetImage", 67 | # "logs:CreateLogStream", 68 | # "logs:PutLogEvents" 69 | } 70 | 71 | resource "aws_security_group" "course_management_sg" { 72 | name = "course-management-alb-sg" 73 | description = "Security Group for ALB" 74 | vpc_id = aws_vpc.course_management_vpc.id 75 | 76 | ingress { 77 | from_port = 80 78 | to_port = 80 79 | protocol = "tcp" 80 | cidr_blocks = ["0.0.0.0/0"] 81 | } 82 | 83 | ingress { 84 | from_port = 443 85 | to_port = 443 86 | protocol = "tcp" 87 | cidr_blocks = ["0.0.0.0/0"] 88 | } 89 | 90 | egress { 91 | from_port = 0 92 | to_port = 0 93 | protocol = "-1" 94 | cidr_blocks = ["0.0.0.0/0"] 95 | } 96 | } -------------------------------------------------------------------------------- /terraform/app_dev.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_group" "couse_management_logs_dev" { 2 | name = "/ecs/course-management-dev" 3 | retention_in_days = 7 4 | 5 | tags = { 6 | Name = "ECS Course Management Logs" 7 | Environment = "Development" 8 | # Add additional tags as needed 9 | } 10 | } 11 | 12 | resource "aws_ecs_task_definition" "dev_course_management_task" { 13 | family = "course-management-dev" 14 | network_mode = "awsvpc" 15 | requires_compatibilities = ["FARGATE"] 16 | cpu = "256" # Adjust as needed 17 | memory = "512" # Adjust as needed 18 | execution_role_arn = aws_iam_role.ecs_execution_role.arn 19 | 20 | container_definitions = jsonencode([{ 21 | name = "course-management-dev", 22 | image = "${aws_ecr_repository.course_management_ecr_repo.repository_url}:${var.dev_tag}", 23 | portMappings = [{ 24 | containerPort = 80, 25 | hostPort = 80 26 | }], 27 | environment = [ 28 | { 29 | name = "DEBUG", 30 | value = "1" 31 | }, 32 | { 33 | name = "DATABASE_URL", 34 | value = "postgresql://${var.db_username}:${var.db_password}@${aws_rds_cluster.dev_course_management_cluster.endpoint}:${aws_rds_cluster.dev_course_management_cluster.port}/dev" 35 | }, 36 | { 37 | NAME = "SECRET_KEY", 38 | value = var.django_key 39 | }, 40 | { 41 | name = "EXTRA_ALLOWED_HOSTS", 42 | value = "dev.courses.datatalks.club,${aws_lb.course_managemenent_alb_dev.dns_name}" 43 | }, 44 | { 45 | NAME = "VERSION", 46 | value = var.dev_tag 47 | } 48 | ], 49 | logConfiguration = { 50 | logDriver = "awslogs" 51 | options = { 52 | awslogs-group = aws_cloudwatch_log_group.couse_management_logs_dev.name 53 | awslogs-region = var.region 54 | awslogs-stream-prefix = "ecs" 55 | } 56 | } 57 | }]) 58 | } 59 | 60 | resource "aws_ecs_service" "course_management_service_dev" { 61 | name = "course-management-dev" 62 | cluster = aws_ecs_cluster.course_management_cluster.id 63 | 64 | task_definition = aws_ecs_task_definition.dev_course_management_task.arn 65 | launch_type = "FARGATE" 66 | 67 | network_configuration { 68 | subnets = [aws_subnet.course_management_subnet.id, aws_subnet.course_management_subnet_2.id] 69 | security_groups = [aws_security_group.db_security_group.id] 70 | } 71 | 72 | load_balancer { 73 | target_group_arn = aws_lb_target_group.course_management_tg_dev.arn 74 | container_name = "course-management-dev" 75 | container_port = 80 76 | } 77 | 78 | desired_count = 1 79 | 80 | lifecycle { 81 | ignore_changes = [ 82 | desired_count, 83 | task_definition, # updated via CI/CD 84 | // Other attributes that Terraform should not update. 85 | ] 86 | } 87 | } 88 | 89 | resource "aws_lb" "course_managemenent_alb_dev" { 90 | name = "course-management-dev" 91 | internal = false 92 | load_balancer_type = "application" 93 | 94 | security_groups = [aws_security_group.course_management_sg.id] 95 | subnets = [aws_subnet.course_management_public_subnet.id, aws_subnet.course_management_public_subnet_2.id] 96 | 97 | enable_deletion_protection = false 98 | } 99 | 100 | resource "aws_lb_target_group" "course_management_tg_dev" { 101 | name = "course-management-dev" 102 | port = 80 103 | protocol = "HTTP" 104 | vpc_id = aws_vpc.course_management_vpc.id 105 | 106 | target_type = "ip" 107 | 108 | health_check { 109 | enabled = true 110 | path = "/ping" 111 | } 112 | } 113 | 114 | resource "aws_lb_listener" "course_managemenent_listener_dev" { 115 | load_balancer_arn = aws_lb.course_managemenent_alb_dev.arn 116 | port = 80 117 | protocol = "HTTP" 118 | 119 | default_action { 120 | type = "redirect" 121 | redirect { 122 | port = "443" 123 | protocol = "HTTPS" 124 | status_code = "HTTP_301" 125 | } 126 | } 127 | } 128 | 129 | resource "aws_lb_listener" "course_managemenent_https_listener_dev" { 130 | load_balancer_arn = aws_lb.course_managemenent_alb_dev.arn 131 | port = 443 132 | protocol = "HTTPS" 133 | ssl_policy = "ELBSecurityPolicy-2016-08" 134 | certificate_arn = var.certificate_arn 135 | 136 | default_action { 137 | type = "forward" 138 | target_group_arn = aws_lb_target_group.course_management_tg_dev.arn 139 | } 140 | } 141 | 142 | resource "aws_route53_record" "dev_subdomain_cname" { 143 | zone_id = "Z00653771YEUL1BFHEDFR" 144 | name = "dev.courses.datatalks.club" 145 | type = "CNAME" 146 | 147 | ttl = 1800 # in seconds 148 | 149 | records = [aws_lb.course_managemenent_alb_dev.dns_name] 150 | } 151 | 152 | output "alb_dev_dns_name" { 153 | value = aws_lb.course_managemenent_alb_dev.dns_name 154 | description = "The DNS name of the dev application load balancer" 155 | } 156 | -------------------------------------------------------------------------------- /terraform/app_prod.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_group" "couse_management_logs_prod" { 2 | name = "/ecs/course-management-prod" 3 | retention_in_days = 7 4 | 5 | tags = { 6 | Name = "ECS Course Management Logs" 7 | Environment = "Production" 8 | # Add additional tags as needed 9 | } 10 | } 11 | 12 | resource "aws_ecs_task_definition" "course_management_task_prod" { 13 | family = "course-management-prod" 14 | network_mode = "awsvpc" 15 | requires_compatibilities = ["FARGATE"] 16 | cpu = "256" # Adjust as needed 17 | memory = "512" # Adjust as needed 18 | execution_role_arn = aws_iam_role.ecs_execution_role.arn 19 | 20 | container_definitions = jsonencode([{ 21 | name = "course-management-prod", 22 | image = "${aws_ecr_repository.course_management_ecr_repo.repository_url}:${var.prod_tag}", 23 | portMappings = [{ 24 | containerPort = 80, 25 | hostPort = 80 26 | }], 27 | environment = [ 28 | { 29 | name = "DEBUG", 30 | value = "0" 31 | }, 32 | { 33 | name = "DATABASE_URL", 34 | value = "postgresql://${var.db_username}:${var.db_password}@${aws_rds_cluster.dev_course_management_cluster.endpoint}:${aws_rds_cluster.dev_course_management_cluster.port}/prod" 35 | }, 36 | { 37 | NAME = "SECRET_KEY", 38 | value = var.django_key 39 | }, 40 | { 41 | name = "EXTRA_ALLOWED_HOSTS", 42 | value = "courses.datatalks.club,${aws_lb.course_managemenent_alb_prod.dns_name}" 43 | }, 44 | { 45 | NAME = "VERSION", 46 | value = var.prod_tag 47 | } 48 | ], 49 | logConfiguration = { 50 | logDriver = "awslogs" 51 | options = { 52 | awslogs-group = aws_cloudwatch_log_group.couse_management_logs_prod.name 53 | awslogs-region = var.region 54 | awslogs-stream-prefix = "ecs" 55 | } 56 | } 57 | }]) 58 | } 59 | 60 | resource "aws_ecs_service" "course_management_service_prod" { 61 | name = "course-management-prod" 62 | cluster = aws_ecs_cluster.course_management_cluster.id 63 | 64 | task_definition = aws_ecs_task_definition.course_management_task_prod.arn 65 | launch_type = "FARGATE" 66 | 67 | network_configuration { 68 | subnets = [aws_subnet.course_management_subnet.id, aws_subnet.course_management_subnet_2.id] 69 | security_groups = [aws_security_group.db_security_group.id] 70 | } 71 | 72 | load_balancer { 73 | target_group_arn = aws_lb_target_group.course_management_tg_prod.arn 74 | container_name = "course-management-prod" 75 | container_port = 80 76 | } 77 | 78 | desired_count = 1 79 | 80 | lifecycle { 81 | ignore_changes = [ 82 | desired_count, 83 | task_definition, # updated via CI/CD 84 | // Other attributes that Terraform should not update. 85 | ] 86 | } 87 | } 88 | 89 | resource "aws_lb" "course_managemenent_alb_prod" { 90 | name = "course-management-prod" 91 | internal = false 92 | load_balancer_type = "application" 93 | 94 | security_groups = [aws_security_group.course_management_sg.id] 95 | subnets = [aws_subnet.course_management_public_subnet.id, aws_subnet.course_management_public_subnet_2.id] 96 | 97 | enable_deletion_protection = false 98 | } 99 | 100 | resource "aws_lb_target_group" "course_management_tg_prod" { 101 | name = "course-management-prod" 102 | port = 80 103 | protocol = "HTTP" 104 | vpc_id = aws_vpc.course_management_vpc.id 105 | 106 | target_type = "ip" 107 | 108 | health_check { 109 | enabled = true 110 | path = "/ping" 111 | } 112 | } 113 | 114 | resource "aws_lb_listener" "course_managemenent_listener_prod" { 115 | load_balancer_arn = aws_lb.course_managemenent_alb_prod.arn 116 | port = 80 117 | protocol = "HTTP" 118 | 119 | default_action { 120 | type = "redirect" 121 | redirect { 122 | port = "443" 123 | protocol = "HTTPS" 124 | status_code = "HTTP_301" 125 | } 126 | } 127 | } 128 | 129 | resource "aws_lb_listener" "course_managemenent_https_listener_prod" { 130 | load_balancer_arn = aws_lb.course_managemenent_alb_prod.arn 131 | port = 443 132 | protocol = "HTTPS" 133 | ssl_policy = "ELBSecurityPolicy-2016-08" 134 | certificate_arn = var.certificate_arn 135 | 136 | default_action { 137 | type = "forward" 138 | target_group_arn = aws_lb_target_group.course_management_tg_prod.arn 139 | } 140 | } 141 | 142 | resource "aws_route53_record" "subdomain_alias_prod" { 143 | zone_id = "Z00653771YEUL1BFHEDFR" 144 | name = "courses.datatalks.club" 145 | type = "A" # Alias 146 | 147 | alias { 148 | name = aws_lb.course_managemenent_alb_prod.dns_name 149 | zone_id = aws_lb.course_managemenent_alb_prod.zone_id 150 | evaluate_target_health = true 151 | } 152 | } 153 | 154 | output "alb_prod_dns_name" { 155 | value = aws_lb.course_managemenent_alb_prod.dns_name 156 | description = "The DNS name of the prod application load balancer" 157 | } 158 | -------------------------------------------------------------------------------- /terraform/bastion.tf: -------------------------------------------------------------------------------- 1 | 2 | # Bastion Host Instance - uncomment when needed 3 | 4 | resource "aws_instance" "bastion_host" { 5 | ami = "ami-0c0a42948ea1b4f44" 6 | instance_type = "t4g.nano" 7 | key_name = "razer" 8 | vpc_security_group_ids = [aws_security_group.public_security_group.id] 9 | 10 | subnet_id = aws_subnet.course_management_public_subnet.id 11 | 12 | # associate_public_ip_address = true 13 | 14 | tags = { 15 | Name = "Course Management Bastion Host" 16 | } 17 | } 18 | 19 | output "bastion_host_public_dns" { 20 | value = aws_instance.bastion_host.public_ip 21 | description = "The public DNS name of the Bastion Host" 22 | } 23 | -------------------------------------------------------------------------------- /terraform/db.tf: -------------------------------------------------------------------------------- 1 | variable "db_username" {} 2 | 3 | variable "db_password" {} 4 | 5 | 6 | # Amazon Aurora Database - DEV 7 | 8 | resource "aws_rds_cluster" "dev_course_management_cluster" { 9 | cluster_identifier = "dev-course-management-cluster" 10 | engine = "aurora-postgresql" 11 | engine_version = "15.10" 12 | database_name = "coursemanagement" 13 | master_username = var.db_username 14 | master_password = var.db_password 15 | db_subnet_group_name = aws_db_subnet_group.course_management_subnet_group.name 16 | vpc_security_group_ids = [aws_security_group.db_security_group.id] 17 | 18 | skip_final_snapshot = true 19 | } 20 | 21 | resource "aws_rds_cluster_instance" "dev_course_management_instances" { 22 | count = 1 23 | identifier = "course-management-instance-${count.index}" 24 | cluster_identifier = aws_rds_cluster.dev_course_management_cluster.id 25 | instance_class = "db.t3.medium" 26 | engine = "aurora-postgresql" 27 | } 28 | -------------------------------------------------------------------------------- /terraform/env-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_username": "pgusr", 3 | "db_password": "PASSWORD", 4 | "django_key": "KEY" 5 | } -------------------------------------------------------------------------------- /terraform/iam_deploy.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_user" "deploy_ci_cd_user" { 2 | name = "course-management-ci-cd-deploy-user" 3 | } 4 | 5 | resource "aws_iam_access_key" "ci_cd_user_key" { 6 | user = aws_iam_user.deploy_ci_cd_user.name 7 | } 8 | 9 | resource "aws_iam_user_policy" "ci_cd_user_policy" { 10 | name = "ci-cd-user-policy" 11 | user = aws_iam_user.deploy_ci_cd_user.name 12 | 13 | policy = jsonencode({ 14 | Version = "2012-10-17", 15 | Statement = [ 16 | { 17 | Effect = "Allow", 18 | Action = "ecr:GetAuthorizationToken", 19 | Resource = "*" 20 | }, 21 | { 22 | Effect = "Allow", 23 | Action = [ 24 | "ecr:GetLoginPassword", 25 | "ecr:BatchCheckLayerAvailability", 26 | "ecr:CompleteLayerUpload", 27 | "ecr:InitiateLayerUpload", 28 | "ecr:PutImage", 29 | "ecr:UploadLayerPart" 30 | ], 31 | Resource = aws_ecr_repository.course_management_ecr_repo.arn 32 | }, 33 | { 34 | Effect = "Allow", 35 | Action = [ 36 | "ecs:DescribeTaskDefinition", 37 | "ecs:RegisterTaskDefinition", 38 | "ecs:ListTaskDefinitions", 39 | "ecs:DeregisterTaskDefinition", 40 | "ecs:DescribeServices" 41 | ], 42 | Resource = "*" 43 | }, 44 | { 45 | Effect = "Allow", 46 | Action = "ecs:UpdateService", 47 | Resource = [ 48 | "arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:service/${aws_ecs_cluster.course_management_cluster.name}/${aws_ecs_service.course_management_service_dev.name}", 49 | "arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:service/${aws_ecs_cluster.course_management_cluster.name}/${aws_ecs_service.course_management_service_prod.name}", 50 | ] 51 | }, 52 | { 53 | Effect = "Allow", 54 | Action = "iam:PassRole", 55 | Resource = aws_iam_role.ecs_execution_role.arn 56 | } 57 | ] 58 | }) 59 | } 60 | 61 | output "ci_cd_user_access_key_id" { 62 | value = aws_iam_access_key.ci_cd_user_key.id 63 | } 64 | 65 | output "ci_cd_user_secret_access_key" { 66 | value = aws_iam_access_key.ci_cd_user_key.secret 67 | sensitive = true 68 | } 69 | -------------------------------------------------------------------------------- /terraform/main.tf: -------------------------------------------------------------------------------- 1 | variable "region" { 2 | default = "eu-west-1" 3 | description = "the AWS region" 4 | } 5 | 6 | provider "aws" { 7 | region = var.region 8 | } 9 | 10 | data "aws_caller_identity" "current" {} 11 | -------------------------------------------------------------------------------- /terraform/vpc.tf: -------------------------------------------------------------------------------- 1 | # VPC 2 | resource "aws_vpc" "course_management_vpc" { 3 | cidr_block = "10.0.0.0/16" 4 | tags = { 5 | Name = "course-management" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /terraform/vpc_private.tf: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Subnet 4 | resource "aws_subnet" "course_management_subnet" { 5 | vpc_id = aws_vpc.course_management_vpc.id 6 | cidr_block = "10.0.1.0/24" 7 | tags = { 8 | Name = "subnet" 9 | } 10 | } 11 | 12 | # Additional Subnet in a different AZ 13 | resource "aws_subnet" "course_management_subnet_2" { 14 | vpc_id = aws_vpc.course_management_vpc.id 15 | cidr_block = "10.0.2.0/24" # Make sure the CIDR block does not overlap with the first subnet 16 | availability_zone = "${var.region}b" 17 | tags = { 18 | Name = "subnet-2" 19 | } 20 | } 21 | 22 | # NAT to allow private instances to access the internet 23 | 24 | resource "aws_eip" "nat_eip" { 25 | domain = "vpc" 26 | } 27 | 28 | resource "aws_nat_gateway" "nat_gateway" { 29 | allocation_id = aws_eip.nat_eip.id 30 | subnet_id = aws_subnet.course_management_public_subnet.id 31 | } 32 | 33 | resource "aws_route_table" "private_route_table" { 34 | vpc_id = aws_vpc.course_management_vpc.id 35 | 36 | route { 37 | cidr_block = "0.0.0.0/0" 38 | nat_gateway_id = aws_nat_gateway.nat_gateway.id 39 | } 40 | } 41 | 42 | resource "aws_route_table_association" "private_subnet_association" { 43 | subnet_id = aws_subnet.course_management_subnet.id 44 | route_table_id = aws_route_table.private_route_table.id 45 | } 46 | 47 | resource "aws_route_table_association" "private_subnet_association_2" { 48 | subnet_id = aws_subnet.course_management_subnet_2.id 49 | route_table_id = aws_route_table.private_route_table.id 50 | } 51 | 52 | # Security Group for the Aurora Database 53 | resource "aws_security_group" "db_security_group" { 54 | name_prefix = "db-sg-" 55 | description = "Security group for the Aurora database" 56 | vpc_id = aws_vpc.course_management_vpc.id 57 | 58 | # Allow all traffic within the VPC 59 | ingress { 60 | from_port = 0 61 | to_port = 0 62 | protocol = "-1" 63 | cidr_blocks = [aws_vpc.course_management_vpc.cidr_block] 64 | } 65 | 66 | # Allow all outbound traffic 67 | egress { 68 | from_port = 0 69 | to_port = 0 70 | protocol = "-1" 71 | cidr_blocks = ["0.0.0.0/0"] 72 | } 73 | } 74 | 75 | # DB Subnet Group 76 | resource "aws_db_subnet_group" "course_management_subnet_group" { 77 | name = "db-subnet-group" 78 | subnet_ids = [aws_subnet.course_management_subnet.id, aws_subnet.course_management_subnet_2.id] 79 | 80 | tags = { 81 | Name = "db-subnet-group" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /terraform/vpc_public.tf: -------------------------------------------------------------------------------- 1 | 2 | resource "aws_subnet" "course_management_public_subnet" { 3 | vpc_id = aws_vpc.course_management_vpc.id 4 | cidr_block = "10.0.3.0/24" # Adjust CIDR block as needed 5 | map_public_ip_on_launch = true # Enable auto-assign public IP 6 | 7 | availability_zone = "${var.region}a" 8 | 9 | tags = { 10 | Name = "course-management-public-subnet" 11 | } 12 | } 13 | 14 | resource "aws_subnet" "course_management_public_subnet_2" { 15 | vpc_id = aws_vpc.course_management_vpc.id 16 | cidr_block = "10.0.4.0/24" # Adjust CIDR block as needed 17 | map_public_ip_on_launch = true # Enable auto-assign public IP 18 | 19 | availability_zone = "${var.region}b" 20 | 21 | tags = { 22 | Name = "course-management-public-subnet" 23 | } 24 | } 25 | 26 | 27 | # Ensure there's a route to an Internet Gateway 28 | resource "aws_internet_gateway" "course_management_gateway" { 29 | vpc_id = aws_vpc.course_management_vpc.id 30 | } 31 | 32 | resource "aws_route_table" "public_route_table" { 33 | vpc_id = aws_vpc.course_management_vpc.id 34 | 35 | route { 36 | cidr_block = "0.0.0.0/0" 37 | gateway_id = aws_internet_gateway.course_management_gateway.id 38 | } 39 | } 40 | 41 | resource "aws_route_table_association" "public_subnet_association" { 42 | subnet_id = aws_subnet.course_management_public_subnet.id 43 | route_table_id = aws_route_table.public_route_table.id 44 | } 45 | 46 | resource "aws_route_table_association" "public_subnet_association_2" { 47 | subnet_id = aws_subnet.course_management_public_subnet_2.id 48 | route_table_id = aws_route_table.public_route_table.id 49 | } 50 | 51 | # Security Group for Bastion Host 52 | resource "aws_security_group" "public_security_group" { 53 | name = "public-course-management-sg" 54 | description = "Security Group for Bastion Host" 55 | vpc_id = aws_vpc.course_management_vpc.id 56 | 57 | ingress { 58 | description = "SSH from the Internet" 59 | from_port = 22 60 | to_port = 22 61 | protocol = "tcp" 62 | cidr_blocks = ["2.215.203.106/32"] 63 | } 64 | 65 | # uncomment to allow access from everywhere 66 | # ingress { 67 | # description = "SSH from the Internet" 68 | # from_port = 22 69 | # to_port = 22 70 | # protocol = "tcp" 71 | # cidr_blocks = ["0.0.0.0/0"] 72 | # } 73 | 74 | egress { 75 | from_port = 0 76 | to_port = 0 77 | protocol = "-1" 78 | cidr_blocks = ["0.0.0.0/0"] 79 | } 80 | 81 | tags = { 82 | Name = "public-course-management-sg" 83 | } 84 | } -------------------------------------------------------------------------------- /tunnel-prod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # echo "connecting to SSH and setting up the SSH tunnel..." 4 | BASTION_NAME="bastion-tunnel" 5 | # ssh -f -N "${BASTION_NAME}" 6 | 7 | # Get the PID of the SSH process for later termination 8 | # SSH_PID=$! 9 | 10 | echo "make sure you have the tunnel open" 11 | echo "run \"ssh ${BASTION_NAME}\" to open the tunnel" 12 | 13 | export DATABASE_URL="postgresql://pgusr:${DB_PASSWORD}@localhost:5433/prod" 14 | export SECRET_KEY="${DJANGO_SECRET}" 15 | 16 | echo "SSH tunnel established. Starting shell..." 17 | /bin/bash 18 | 19 | # When the shell is closed, kill the SSH tunnel 20 | echo "Shell closed. Terminating SSH tunnel..." 21 | # kill $SSH_PID 22 | --------------------------------------------------------------------------------