diff --git a/.gitignore b/.gitignore index 0e087d7..1b230e1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,15 @@ postgres/sql/01_data.sql # django django/**/migrations/** django/university/models.py +django/.env.dev + +nginx/certs/*.key # celery django/celerybeat-schedule # stats related graphs django/university/stats_graphs/* + +httpd/shibboleth/*.pem + diff --git a/django/.env.dev b/django/.env.dev deleted file mode 100644 index 50aaa05..0000000 --- a/django/.env.dev +++ /dev/null @@ -1,13 +0,0 @@ -DEBUG=1 -SECRET_KEY=foo - -POSTGRES_DB=tts -POSTGRES_USER=root -POSTGRES_PASSWORD=root -POSTGRES_HOST=db -POSTGRES_PORT=5432 - -TTS_REDIS_HOST=tts_redis -TTS_REDIS_PORT=6379 -TTS_REDIS_USERNAME= -TTS_REDIS_PASSWORD= diff --git a/django/.env.example b/django/.env.example new file mode 100644 index 0000000..353ef8e --- /dev/null +++ b/django/.env.example @@ -0,0 +1,28 @@ +DEBUG=1 +SECRET_KEY=foo + +JWT_KEY=change_in_production + +DOMAIN=http://localhost:3100/ + +POSTGRES_DB=tts +POSTGRES_USER=root +POSTGRES_PASSWORD=root +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +TTS_REDIS_HOST=tts_redis +TTS_REDIS_PORT=6379 +TTS_REDIS_USERNAME= +TTS_REDIS_PASSWORD= + +SENDER_EMAIL_ADDRESS= +STUDENT_EMAIL_DOMAIN=@up.pt + +VERIFY_EXCHANGE_TOKEN_EXPIRATION_SECONDS=86400 + +OIDC_RP_CLIENT_ID= +OIDC_RP_CLIENT_SECRET= + +SIGARRA_USERNAME= +SIGARRA_PASSWORD= diff --git a/django/entrypoint.sh b/django/entrypoint.sh index b7e8af4..c7a44d6 100755 --- a/django/entrypoint.sh +++ b/django/entrypoint.sh @@ -1,31 +1,36 @@ -#!/bin/sh +#!/bin/sh -# WARNING: The script will not work if formated with CRLF. +# WARNING: The script will not work if formated with CRLF. -# Configure the shell behaviour. +# Configure the shell behaviour. set -e -if [[ "${DEBUG}" == 1 ]] -then set -x -fi +if [[ "${DEBUG}" == 1 ]]; then + set -x +fi # Get parameters. cmd="$@" -# Waits for PostgreSQL initialization. +# Waits for PostgreSQL initialization. until PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${POSTGRES_HOST}" -U "${POSTGRES_USER}" "${POSTGRES_DB}" -c 'select 1'; do - >&2 echo "PostgreSQL is unavailable - sleeping" - sleep 4 + >&2 echo "PostgreSQL is unavailable - sleeping" + sleep 4 done ->&2 echo "PostgreSQL is up - executing command" +>&2 echo "PostgreSQL is up - executing command" + +echo "ENTRYPOINT RAN" # Migrate the Django. -python manage.py inspectdb > university/models.py python manage.py makemigrations +python manage.py migrate +python manage.py migrate --fake sessions zero python manage.py migrate university --fake +python manage.py migrate --fake-initial +python manage.py inspectdb >university/models.py # Initialize redis worker for celery and celery's beat scheduler in the background celery -A tasks worker --loglevel=INFO & celery -A tasks beat & -# Initializes the API. +# Initializes the API. exec $cmd diff --git a/django/entrypoint_prod.sh b/django/entrypoint_prod.sh index b77e049..0fe765f 100755 --- a/django/entrypoint_prod.sh +++ b/django/entrypoint_prod.sh @@ -10,6 +10,7 @@ done # Migrate the Django. python manage.py inspectdb >university/models.py python manage.py makemigrations +python manage.py migrate python manage.py migrate university --fake python manage.py runserver 0.0.0.0:8000 diff --git a/django/requirements.txt b/django/requirements.txt index 3f22b3b..4bb0200 100644 --- a/django/requirements.txt +++ b/django/requirements.txt @@ -8,6 +8,9 @@ psycopg2==2.9.9 celery==5.2.7 redis==3.5.3 python-dotenv==1.0.1 +requests==2.31.0 +pyjwt==2.8.0 +mozilla-django-oidc==4.0.1 channels==4.1.0 daphne==4.1.2 python-socketio==5.11.4 diff --git a/django/tts_be/settings.py b/django/tts_be/settings.py index 8aa93d4..7106140 100644 --- a/django/tts_be/settings.py +++ b/django/tts_be/settings.py @@ -12,8 +12,13 @@ from pathlib import Path import os +from dotenv import dotenv_values +CONFIG={ + **dotenv_values(".env"), # load variables + **os.environ, # override loaded values with environment variables +} # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -22,12 +27,21 @@ # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ -SECRET_KEY = os.getenv('SECRET_KEY') +SECRET_KEY = CONFIG['SECRET_KEY'] + +JWT_KEY= CONFIG['JWT_KEY'] DEBUG = os.getenv('DEBUG') DEBUG = int(DEBUG) != 0 if DEBUG else False -ALLOWED_HOSTS = ['0.0.0.0', 'localhost', 'tts.niaefeup.pt', 'tts-staging.niaefeup.pt'] +DOMAIN = os.getenv('DOMAIN') +DEBUG = False if int(CONFIG['DEBUG']) == 0 else True + +ALLOWED_HOSTS = ['tts.niaefeup.pt', 'tts-staging.niaefeup.pt'] + +if DEBUG: + ALLOWED_HOSTS.extend(['localhost', 'tts-dev.niaefeup.pt']) + # Application definition @@ -37,10 +51,11 @@ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', - #'django.contrib.sessions', # legacy + 'django.contrib.sessions', # legacy 'django.contrib.messages', 'rest_framework', 'django.contrib.staticfiles', + 'mozilla_django_oidc', 'university', 'channels', ] @@ -51,18 +66,24 @@ 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'university.auth_middleware.AuthMiddleware', + 'mozilla_django_oidc.middleware.SessionRefresh', + 'django.middleware.csrf.CsrfViewMiddleware' ] +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + ROOT_URLCONF = 'tts_be.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [ + os.path.join(BASE_DIR, 'university/exchange/emails') + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -75,10 +96,35 @@ }, ] + + WSGI_APPLICATION = 'tts_be.wsgi.application' -ASGI_APPLICATION = 'tts_be.asgi.application' +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'university.auth.CustomOIDCAuthentationBackend' +) + +OIDC_RP_CLIENT_ID = os.environ['OIDC_RP_CLIENT_ID'] +OIDC_RP_CLIENT_SECRET = os.environ['OIDC_RP_CLIENT_SECRET'] +OIDC_RP_SIGN_ALGO = "RS256" + +OIDC_STORE_ID_TOKEN = True +OIDC_STORE_ACCESS_TOKEN = True + +OIDC_OP_AUTHORIZATION_ENDPOINT = "https://open-id.up.pt/realms/sigarra/protocol/openid-connect/auth" +OIDC_OP_TOKEN_ENDPOINT = "https://open-id.up.pt/realms/sigarra/protocol/openid-connect/token" +OIDC_OP_USER_ENDPOINT = "https://open-id.up.pt/realms/sigarra/protocol/openid-connect/userinfo" +OIDC_OP_JWKS_ENDPOINT = "https://open-id.up.pt/realms/sigarra/protocol/openid-connect/certs" +OIDC_OP_LOGOUT_ENDPOINT = "https://open-id.up.pt/realms/sigarra/protocol/openid-connect/logout" +OIDC_RP_SCOPES = "openid email profile uporto_data" + +LOGIN_REDIRECT_URL = "/" + +OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = 3600 * 60 + +ASGI_APPLICATION = 'tts_be.asgi.application' # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases @@ -125,7 +171,6 @@ USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ @@ -144,7 +189,24 @@ 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', ] +} +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': f"redis://{CONFIG['TTS_REDIS_HOST']}:{CONFIG['TTS_REDIS_PORT']}//" + } } -CORS_ORIGIN_ALLOW_ALL = True +CORS_ORIGIN_ALLOW_ALL = bool(DEBUG) +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_HEADERS = [ + "X-CSRFToken" +] + +VERIFY_EXCHANGE_TOKEN_EXPIRATION_SECONDS = int(os.getenv("VERIFY_EXCHANGE_TOKEN_EXPIRATION_SECONDS", 3600 * 24)) + +EMAIL_HOST = os.getenv("EMAIL_HOST", "tts-mailpit") +EMAIL_PORT = os.getenv("EMAIL_PORT", 1025) +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", None) +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", None) diff --git a/django/university/admin.py b/django/university/admin.py index 8c38f3f..e69de29 100644 --- a/django/university/admin.py +++ b/django/university/admin.py @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/django/university/auth.py b/django/university/auth.py new file mode 100644 index 0000000..abfcd09 --- /dev/null +++ b/django/university/auth.py @@ -0,0 +1,29 @@ +from mozilla_django_oidc.auth import OIDCAuthenticationBackend +from university.controllers.SigarraController import SigarraController +from university.controllers.StudentController import StudentController +from university.models import UserCourseUnits, Class + +class CustomOIDCAuthentationBackend(OIDCAuthenticationBackend): + + def create_user(self, claims): + user = super(CustomOIDCAuthentationBackend, self).create_user(claims) + + user.first_name = claims.get('given_name', '') + user.last_name = claims.get('family_name', '').split(' ')[-1] + user.username = claims.get('nmec', '') + user.password = "" # User does not have password + user.save() + + StudentController.populate_user_course_unit_data(user.username) + + return user + + def update_user(self, user, claims): + user.first_name = claims.get('given_name', '') + user.last_name = claims.get('family_name', '').split(' ')[-1] + user.save() + + StudentController.populate_user_course_unit_data(user.username, erase_previous=True) + + return user + diff --git a/django/university/auth_middleware.py b/django/university/auth_middleware.py new file mode 100644 index 0000000..7ed3202 --- /dev/null +++ b/django/university/auth_middleware.py @@ -0,0 +1,43 @@ +import re + +from django.http import HttpResponseForbidden + +class AuthMiddleware: + def __init__(self, get_response): + self.get_response = get_response + self.auth_paths = [ + '/logout/', + '/auth/info/', + '/student/schedule/', + re.compile(r'^/student/\w+/photo/$'), + re.compile(r'^/schedule_sigarra/\d+/$'), + re.compile(r'^/class_sigarra_schedule/\d+/.+/$'), + re.compile(r'^/exchange/marketplace/$'), + re.compile(r'^/exchange/direct/$'), + re.compile(r'^/exchange/options/$'), + '/is_admin/', + '/export/', + '/direct_exchange/history/', + '/marketplace_exchange/', + '/submit_marketplace_exchange/', + ] + + def __call__(self, request): + in_paths = False + + for path in self.auth_paths: + if isinstance(path, str) and request.path == path: + in_paths = True + break + elif isinstance(path, re.Pattern) and path.match(request.path): + in_paths = True + break + + if not in_paths: + return self.get_response(request) + + if not request.user.is_authenticated: + return HttpResponseForbidden() + + return self.get_response(request) + diff --git a/django/university/controllers/ExchangeController.py b/django/university/controllers/ExchangeController.py new file mode 100644 index 0000000..9958778 --- /dev/null +++ b/django/university/controllers/ExchangeController.py @@ -0,0 +1,151 @@ +import base64 +import json + +from django.core.paginator import Paginator +from university.controllers.ClassController import ClassController +from university.exchange.utils import ExchangeStatus, check_class_schedule_overlap, course_unit_by_id +from university.models import DirectExchange, DirectExchangeParticipants, ExchangeExpirations, MarketplaceExchange +from django.utils import timezone +from enum import Enum + +from university.models import UserCourseUnits + +from university.serializers.DirectExchangeParticipantsSerializer import DirectExchangeParticipantsSerializer +from university.serializers.MarketplaceExchangeClassSerializer import MarketplaceExchangeClassSerializer + +class ExchangeType(Enum): + MARKETPLACE_EXCHANGE = 1 + DIRECT_EXCHANGE = 2 + + def toString(self): + return "marketplaceexchange" if self == ExchangeType.MARKETPLACE_EXCHANGE else "directexchange" + +class DirectExchangePendingMotive(Enum): + USER_DID_NOT_ACCEPT = 1 + OTHERS_DID_NOT_ACCEPT = 2 + + @staticmethod + def get_pending_motive(curr_user_nmec: str, direct_exchange: DirectExchange): + participants = list(DirectExchangeParticipants.objects.filter( + participant_nmec=curr_user_nmec, direct_exchange=direct_exchange).all() + ) + + for participant in participants: + if not participant.accepted: + return DirectExchangePendingMotive.USER_DID_NOT_ACCEPT + + if not direct_exchange.accepted: + return DirectExchangePendingMotive.OTHERS_DID_NOT_ACCEPT + + @staticmethod + def get_value(pending_motive): + return pending_motive.value + + +class ExchangeController: + @staticmethod + def eligible_course_units(nmec): + course_units = UserCourseUnits.objects.filter(user_nmec=nmec).values_list("course_unit_id", flat=True) + + exchange_expirations = ExchangeExpirations.objects.filter( + course_unit_id__in=course_units, + active_date__lte=timezone.now(), + end_date__gte=timezone.now(), + ).values_list("course_unit_id", flat=True) + + return list(exchange_expirations) + + @staticmethod + def getExchangeType(exchange) -> ExchangeType: + if type(exchange) == MarketplaceExchange: + return ExchangeType.MARKETPLACE_EXCHANGE + + return ExchangeType.DIRECT_EXCHANGE + + @staticmethod + def getOptionsDependinOnExchangeType(exchange): + if type(exchange) == MarketplaceExchange: + return [MarketplaceExchangeClassSerializer(exchange_class).data for exchange_class in exchange.options] + elif type(exchange) == DirectExchange: + return [DirectExchangeParticipantsSerializer(participant).data for participant in exchange.options] + + @staticmethod + def getExchangeOptionClasses(options): + classes = sum(list(map(lambda option: ClassController.get_classes(option.course_unit_id), options)), []) + return filter(lambda currentClass: any(currentClass["name"] == option.class_issuer_goes_from for option in options), classes) + + @staticmethod + def courseUnitNameFilterInExchangeOptions(options, courseUnitNameFilter): + matches = [] + for courseUnitId in courseUnitNameFilter: + for option in options: + if courseUnitId == option.course_unit_id: + matches.append(1) + + return len(matches) == len(courseUnitNameFilter) + + @staticmethod + def parseClassesFilter(classesFilter: str) -> dict: + if not classesFilter: + return {} + + b64_decoded = base64.b64decode(classesFilter) + string = b64_decoded.decode('utf-8') + return dict(json.loads(string)) + + @staticmethod + def create_direct_exchange_participants(student_schedules, exchanges, inserted_exchanges, exchange_db_model, auth_user): + if ExchangeController.exchange_overlap(student_schedules, auth_user): + return (ExchangeStatus.CLASSES_OVERLAP, None) + + for curr_exchange in exchanges: + other_student = curr_exchange["other_student"] + + course_unit = course_unit_by_id(curr_exchange["course_unit_id"]) + + if ExchangeController.exchange_overlap(student_schedules, other_student): + return (ExchangeStatus.CLASSES_OVERLAP, None) + + inserted_exchanges.append(DirectExchangeParticipants( + participant_name=curr_exchange["other_student"], + participant_nmec=curr_exchange["other_student"], + class_participant_goes_from=curr_exchange["class_participant_goes_from"], + class_participant_goes_to=curr_exchange["class_participant_goes_to"], + course_unit=course_unit.acronym, + course_unit_id=curr_exchange["course_unit_id"], + direct_exchange=exchange_db_model, + accepted=False + )) + + inserted_exchanges.append(DirectExchangeParticipants( + participant_name=auth_user, + participant_nmec=auth_user, + class_participant_goes_from=curr_exchange["class_participant_goes_to"], # This is not a typo, the old class of the authenticted student is the new class of the other student + class_participant_goes_to=curr_exchange["class_participant_goes_from"], + course_unit=course_unit.acronym, + course_unit_id=curr_exchange["course_unit_id"], + direct_exchange=exchange_db_model, + accepted=False + )) + + return (ExchangeStatus.SUCCESS, None) + + @staticmethod + def exchange_overlap(student_schedules, username) -> bool: + for (key, class_schedule) in student_schedules[username].items(): + for (other_key, other_class_schedule) in student_schedules[username].items(): + print(f"({key}, {other_key})") + if key == other_key: + continue + + (class_schedule_day, class_schedule_start, class_schedule_end) = (class_schedule["dia"], class_schedule["hora_inicio"], class_schedule["aula_duracao"] + class_schedule["hora_inicio"]) + (overlap_param_day, overlap_param_start, overlap_param_end) = (other_class_schedule["dia"], other_class_schedule["hora_inicio"], other_class_schedule["aula_duracao"] + other_class_schedule["hora_inicio"]) + + if check_class_schedule_overlap(class_schedule_day, class_schedule_start, class_schedule_end, overlap_param_day, overlap_param_start, overlap_param_end): + return True + + return False + + + + diff --git a/django/university/controllers/SigarraController.py b/django/university/controllers/SigarraController.py new file mode 100644 index 0000000..41728dc --- /dev/null +++ b/django/university/controllers/SigarraController.py @@ -0,0 +1,114 @@ +import requests +import json + +from datetime import date +from tts_be.settings import CONFIG + +class SigarraResponse: + def __init__(self, data, status_code): + self.data = data + self.status_code = status_code + +class SigarraController: + def __init__(self): + self.username = CONFIG["SIGARRA_USERNAME"] + self.password = CONFIG["SIGARRA_PASSWORD"] + self.cookies = None + + self.login() + + def get_student_photo_url(self, nmec) -> str: + return f"https://sigarra.up.pt/feup/pt/fotografias_service.foto?pct_cod={nmec}" + + def semester_weeks(self): + currdate = date.today() + year = str(currdate.year) + first_semester = currdate.month >= 9 and currdate.month <= 12 + if first_semester: + semana_ini = "1001" + semana_fim = "1231" + else: + semana_ini = "0210" + semana_fim = "0601" + + return (year + semana_ini, year + semana_fim) + + def student_schedule_url(self, nmec, semana_ini, semana_fim) -> str: + return f"https://sigarra.up.pt/feup/pt/mob_hor_geral.estudante?pv_codigo={nmec}&pv_semana_ini={semana_ini}&pv_semana_fim={semana_fim}" + + def course_unit_schedule_url(self, ocorrencia_id, semana_ini, semana_fim): + return f"https://sigarra.up.pt/feup/pt/mob_hor_geral.ucurr?pv_ocorrencia_id={ocorrencia_id}&pv_semana_ini={semana_ini}&pv_semana_fim={semana_fim}" + + def retrieve_student_photo(self, nmec): + response = requests.get(self.get_student_photo_url(nmec), cookies=self.cookies) + + if response.status_code != 200: + return SigarraResponse(None, response.status_code) + + return SigarraResponse(response.content, 200) + + def login(self): + try: + response = requests.post("https://sigarra.up.pt/feup/pt/mob_val_geral.autentica/", data={ + "pv_login": self.username, + "pv_password": self.password + }) + + self.cookies = response.cookies + except requests.exceptions.RequestException as e: + print("Error: ", e) + + def get_student_schedule(self, nmec: int) -> SigarraResponse: + (semana_ini, semana_fim) = self.semester_weeks() + + response = requests.get(self.student_schedule_url( + nmec, + semana_ini, + semana_fim + ), cookies=self.cookies) + + if(response.status_code != 200): + return SigarraResponse(None, response.status_code) + + return SigarraResponse(response.json()['horario'], response.status_code) + + def get_student_course_units(self, nmec: int) -> SigarraResponse: + schedule = self.get_student_schedule(nmec) + + if schedule.status_code != 200: + return SigarraResponse(None, schedule.status_code) + + course_units = set() + for scheduleItem in schedule.data: + course_units.add(scheduleItem["ocorrencia_id"]) + + return SigarraResponse(list(course_units), 200) + + def get_course_unit_classes(self, course_unit_id: int) -> SigarraResponse: + url = f"https://sigarra.up.pt/feup/pt/mob_ucurr_geral.uc_inscritos?pv_ocorrencia_id={course_unit_id}" + response = requests.get(url, cookies=self.cookies) + + if response.status_code != 200: + return SigarraResponse(None, response.status_code) + + return SigarraResponse(response.json(), 200) + + def get_class_schedule(self, course_unit_id: int, class_name: str) -> SigarraResponse: + (semana_ini, semana_fim) = self.semester_weeks() + + response = requests.get(self.course_unit_schedule_url( + course_unit_id, + semana_ini, + semana_fim + ), cookies=self.cookies) + + if(response.status_code != 200): + return SigarraResponse(None, response.status_code) + + classes = json.loads(response.content)["horario"] + class_schedule = list(filter(lambda c: c["turma_sigla"] == class_name, classes)) + theoretical_schedule = list(filter(lambda c: c["tipo"] == "T" and any(schedule["turma_sigla"] == class_name for schedule in c["turmas"]), classes)) + + return SigarraResponse((class_schedule, theoretical_schedule), 200) + + diff --git a/django/university/controllers/StudentController.py b/django/university/controllers/StudentController.py new file mode 100644 index 0000000..b1be105 --- /dev/null +++ b/django/university/controllers/StudentController.py @@ -0,0 +1,33 @@ +from university.routes.student.schedule.StudentScheduleView import StudentScheduleView +from university.controllers.SigarraController import SigarraController + +from university.models import UserCourseUnits, Class + +class StudentController: + """ + This class will contain methods to manipulate student data that is not on sigarra. + + Handling of student data hosted in sigarra will be done in SigarraController while the handling + of student data hosted inside our database will be done here. + """ + + @staticmethod + def populate_user_course_unit_data(nmec: int, erase_previous: bool = False): + if(erase_previous): + UserCourseUnits.objects.filter(user_nmec=nmec).delete() + + course_units = StudentScheduleView.retrieveCourseUnitClasses(SigarraController(), nmec) + + for item in course_units: + (course_unit_id, class_acronym) = item + corresponding_class = Class.objects.filter(course_unit__id=course_unit_id, name=class_acronym).first() + + if not corresponding_class: + continue + + user_course_unit = UserCourseUnits( + user_nmec=nmec, + course_unit_id=course_unit_id, + class_field=corresponding_class + ) + user_course_unit.save() \ No newline at end of file diff --git a/django/university/exchange/emails/confirm_exchange.html b/django/university/exchange/emails/confirm_exchange.html new file mode 100644 index 0000000..6274c38 --- /dev/null +++ b/django/university/exchange/emails/confirm_exchange.html @@ -0,0 +1,10 @@ + + +
+Podes confirmar a tua troca a partir do seguinte link:
+ + diff --git a/django/university/exchange/utils.py b/django/university/exchange/utils.py new file mode 100644 index 0000000..b907f59 --- /dev/null +++ b/django/university/exchange/utils.py @@ -0,0 +1,232 @@ +from datetime import date +from university.controllers.SigarraController import SigarraController +from university.models import CourseUnit, DirectExchangeParticipants, MarketplaceExchange, MarketplaceExchangeClass, Professor, DirectExchange +from enum import Enum +import requests + +class ExchangeStatus(Enum): + FETCH_SCHEDULE_ERROR = 1 + STUDENTS_NOT_ENROLLED = 2 + CLASSES_OVERLAP = 3 + SUCCESS = 4 + +def get_student_data(username, cookies): + url = f"https://sigarra.up.pt/feup/pt/mob_fest_geral.perfil?pv_codigo={username}" + response = requests.get(url, cookies=cookies) + return response + +def get_student_schedule_url(username, semana_ini, semana_fim): + return f"https://sigarra.up.pt/feup/pt/mob_hor_geral.estudante?pv_codigo={username}&pv_semana_ini={semana_ini}&pv_semana_fim={semana_fim}" + +def create_marketplace_exchange_on_db(exchanges, curr_student): + marketplace_exchange = MarketplaceExchange.objects.create(issuer=curr_student, accepted=False) + for exchange in exchanges: + course_unit = course_unit_by_id(exchange["course_unit_id"]) + MarketplaceExchangeClass.objects.create(marketplace_exchange=marketplace_exchange, course_unit_acronym=course_unit.acronym, course_unit_id=exchange["course_unit_id"], course_unit_name=exchange["course_unit"], old_class=exchange["old_class"], new_class=exchange["new_class"]) + + +def build_marketplace_submission_schedule(schedule, submission, auth_student): + print("Current auth student: ", auth_student) + for exchange in submission: + course_unit = exchange["courseUnitId"] + class_auth_student_goes_to = exchange["classNameRequesterGoesTo"] + class_auth_student_goes_from = exchange["classNameRequesterGoesFrom"] + + print("schedule is: ", schedule[auth_student]) + + auth_user_valid = (class_auth_student_goes_from, course_unit) in schedule[auth_student] + if not(auth_user_valid): + return (ExchangeStatus.STUDENTS_NOT_ENROLLED, None) + + schedule[auth_student][(class_auth_student_goes_to, course_unit)] = SigarraController().get_class_schedule(schedule[auth_student][(class_auth_student_goes_from, course_unit)]["ocorrencia_id"], class_auth_student_goes_to).data[0][0]# get class schedule + del schedule[auth_student][(class_auth_student_goes_from, course_unit)] # remove old class of other student + + return (ExchangeStatus.SUCCESS, None) + +def get_unit_schedule_url(ocorrencia_id, semana_ini, semana_fim): + return f"https://sigarra.up.pt/feup/pt/mob_hor_geral.ucurr?pv_ocorrencia_id={ocorrencia_id}&pv_semana_ini={semana_ini}&pv_semana_fim={semana_fim}" + +def build_new_schedules(student_schedules, exchanges, auth_username): + for curr_exchange in exchanges: + # There are 2 students involved in the exchange. THe other student is the student other than the currently authenticated user + other_student = curr_exchange["other_student"]["mecNumber"] + course_unit = CourseUnit.objects.get(pk=curr_exchange["courseUnitId"]) + course_unit = course_unit.acronym + class_auth_student_goes_to = curr_exchange["classNameRequesterGoesTo"] + class_other_student_goes_to = curr_exchange["classNameRequesterGoesFrom"] # The other student goes to its new class + + # If participant is neither enrolled in that course unit or in that class + other_student_valid = (class_auth_student_goes_to, course_unit) in student_schedules[other_student] + auth_user_valid = (class_other_student_goes_to, course_unit) in student_schedules[auth_username] + + if not(other_student_valid) or not(auth_user_valid): + return (ExchangeStatus.STUDENTS_NOT_ENROLLED, None) + + user_uc = (class_auth_student_goes_to, course_unit) + other_user_uc = (class_other_student_goes_to, course_unit) + + (student_schedules[auth_username][user_uc], student_schedules[other_student][other_user_uc]) = (student_schedules[other_student][user_uc], student_schedules[auth_username][other_user_uc]) + + # Remove class the other student is going from and will not be in anymore + del student_schedules[other_student][user_uc] + + # Remove class the auth student is going from and will not be in anymore + del student_schedules[auth_username][other_user_uc] + + return (ExchangeStatus.SUCCESS, None) + +def build_student_schedule_dicts(student_schedules, exchanges): + for curr_exchange in exchanges: + curr_username = curr_exchange["other_student"]["mecNumber"] + if not curr_username in student_schedules.keys(): + sigarra_res = SigarraController().get_student_schedule(curr_username) + if(sigarra_res.status_code != 200): + return (ExchangeStatus.FETCH_SCHEDULE_ERROR, sigarra_res.status_code) + + schedule = sigarra_res.data + + student_schedules[curr_username] = build_student_schedule_dict(schedule) + + return (ExchangeStatus.SUCCESS, None) + +def build_student_schedule_dict(schedule: list): + return { + (class_schedule["turma_sigla"], class_schedule["ocorrencia_id"]): class_schedule for class_schedule in schedule if class_schedule["tipo"] == "TP" + } + +def check_class_schedule_overlap(day_1: int, start_1: int, end_1: int, day_2: int, start_2: int, end_2: int) -> bool: + if day_1 != day_2: + return False + + if (start_2 >= start_1 and start_2 <= end_1) or (start_1 >= start_2 and start_1 <= end_2): + return True + + return False + + +def exchange_overlap(student_schedules, username) -> bool: + for (key, class_schedule) in student_schedules[username].items(): + for (other_key, other_class_schedule) in student_schedules[username].items(): + print(f"({key}, {other_key})") + if key == other_key: + continue + + (class_schedule_day, class_schedule_start, class_schedule_end) = (class_schedule["dia"], class_schedule["hora_inicio"], class_schedule["aula_duracao"] + class_schedule["hora_inicio"]) + (overlap_param_day, overlap_param_start, overlap_param_end) = (other_class_schedule["dia"], other_class_schedule["hora_inicio"], other_class_schedule["aula_duracao"] + other_class_schedule["hora_inicio"]) + + if check_class_schedule_overlap(class_schedule_day, class_schedule_start, class_schedule_end, overlap_param_day, overlap_param_start, overlap_param_end): + return True + + return False + +""" + Returns name of course unit given its id +""" +def course_unit_name(course_unit_id): + course_unit = CourseUnit.objects.get(sigarra_id=course_unit_id) + return course_unit.name + +""" + Returns the course unit given its acronym +""" +def course_unit_by_id(id): + course_units = CourseUnit.objects.filter(sigarra_id=id) + return course_units.first() + +def curr_semester_weeks(): + currdate = date.today() + year = str(currdate.year) + primeiro_semestre = currdate.month >= 9 and currdate.month <= 12 + if primeiro_semestre: + semana_ini = "1001" + semana_fim = "1201" + else: + semana_ini = "0101" + semana_fim = "0601" + return (year+semana_ini, year+semana_fim) + +def incorrect_class_error() -> str: + return "students-with-incorrect-classes" + +def append_tts_info_to_sigarra_schedule(schedule): + course_unit = CourseUnit.objects.filter(sigarra_id=schedule['ocorrencia_id'])[0] + + schedule['url'] = course_unit.url + # The sigarra api does not return the course with the full name, just the acronym + schedule['ucurr_nome'] = course_unit_name(schedule['ocorrencia_id']) + +def convert_sigarra_schedule(schedule_data): + new_schedule_data = [] + + for schedule in schedule_data: + course_unit = CourseUnit.objects.filter(id=schedule['ocorrencia_id'])[0] + professors = [] + for docente in schedule['docentes']: + professor = Professor.objects.filter(id=docente['doc_codigo']) + if(len(professor) < 1): + continue + professors.append({"name": docente['doc_nome'], "acronym": professor[0].professor_acronym}) + + new_schedule = { + 'courseInfo': { + 'id': schedule['ocorrencia_id'], + 'course_unit_id': schedule['ocorrencia_id'], + 'acronym': course_unit.acronym, + 'name': course_unit.name, + 'url': course_unit.url + }, + 'classInfo': { + 'id': schedule['ocorrencia_id'], + 'name': schedule['turma_sigla'], + 'filteredTeachers': [], + 'slots': [{ + 'id': schedule['ocorrencia_id'], + 'lesson_type': schedule["tipo"], + 'day': schedule['dia'] - 2, + 'start_time': str(schedule['hora_inicio'] / 3600), + 'duration': schedule['aula_duracao'], + 'location': schedule['sala_sigla'], + 'professors_link': '', + 'professors': professors + }], + } + } + + new_schedule_data.append(new_schedule) + + return new_schedule_data + +def update_schedule_accepted_exchanges(student, schedule): + direct_exchange_ids = DirectExchangeParticipants.objects.filter( + participant_nmec=student, direct_exchange__accepted=True + ).values_list('direct_exchange', flat=True) + direct_exchanges = DirectExchange.objects.filter(id__in=direct_exchange_ids).order_by('date') + + for exchange in direct_exchanges: + participants = DirectExchangeParticipants.objects.filter(direct_exchange=exchange, participant=student).order_by('date') + (status, trailing) = update_schedule(schedule, participants) + if status == ExchangeStatus.FETCH_SCHEDULE_ERROR: + return (ExchangeStatus.FETCH_SCHEDULE_ERROR, trailing) + + return (ExchangeStatus.SUCCESS, None) + +def update_schedule(student_schedule, exchanges): + for exchange in exchanges: + for i, schedule in enumerate(student_schedule): + if schedule["ucurr_sigla"] == exchange.course_unit: + ocorr_id = schedule["ocorrencia_id"] + class_type = schedule["tipo"] + + sigarra_res = SigarraController().get_course_unit_classes(ocorr_id) + + if sigarra_res.status_code != 200: + return (ExchangeStatus.FETCH_SCHEDULE_ERROR, sigarra_res.status_code) + + # TODO if old_class schedule is different from current schedule, abort + schedule = sigarra_res.data + for unit_schedule in schedule: + for turma in unit_schedule["turmas"]: + if turma["turma_sigla"] == exchange.new_class and unit_schedule["tipo"] == class_type: + student_schedule[i] = unit_schedule + + return (ExchangeStatus.SUCCESS, None) diff --git a/django/university/routes/MarketplaceExchangeView.py b/django/university/routes/MarketplaceExchangeView.py new file mode 100644 index 0000000..2228606 --- /dev/null +++ b/django/university/routes/MarketplaceExchangeView.py @@ -0,0 +1,151 @@ +import json +from rest_framework.views import APIView +from django.core.paginator import Paginator +from django.http import HttpResponse, JsonResponse +from django.db.models import Q, Prefetch + +from university.controllers.ExchangeController import ExchangeController +from university.controllers.SigarraController import SigarraController +from university.exchange.utils import ExchangeStatus, build_marketplace_submission_schedule, build_student_schedule_dict, exchange_overlap, incorrect_class_error, update_schedule_accepted_exchanges +from university.models import CourseUnit, MarketplaceExchange, MarketplaceExchangeClass, UserCourseUnits, Class +from university.serializers.MarketplaceExchangeClassSerializer import MarketplaceExchangeClassSerializer + +class MarketplaceExchangeView(APIView): + def build_pagination_payload(self, request, exchanges): + page_number = request.GET.get("page") + paginator = Paginator(exchanges, 10) + page_obj = paginator.get_page(page_number if page_number != None else 1) + + return { + "page": { + "current": page_obj.number, + "has_next": page_obj.has_next(), + "has_previous": page_obj.has_previous(), + }, + "data": [{ + "id": exchange.id, + "type": "marketplaceexchange", + "issuer_name": exchange.issuer_name, + "issuer_nmec": exchange.issuer_nmec, + "options": [ + MarketplaceExchangeClassSerializer(exchange_class).data for exchange_class in exchange.options + ], + "classes": list(ExchangeController.getExchangeOptionClasses(exchange.options)), + "date": exchange.date, + "accepted": exchange.accepted + } for exchange in page_obj] + } + + def filterAllExchanges(self, request, course_unit_name_filter, classes_filter): + print("classes filter: ", classes_filter) + marketplace_exchanges = list(MarketplaceExchange.objects + .prefetch_related( + Prefetch( + 'marketplaceexchangeclass_set', + to_attr='options' + ) + ).exclude(issuer_nmec=request.user.username).all()) + + marketplace_exchanges = self.remove_invalid_dest_class_exchanges(marketplace_exchanges, request.user.username) + marketplace_exchanges = self.advanced_classes_filter(marketplace_exchanges, classes_filter) + + if course_unit_name_filter: + marketplace_exchanges = list(filter( + lambda x: ExchangeController.courseUnitNameFilterInExchangeOptions(x.options, course_unit_name_filter), + marketplace_exchanges + )) + + return self.build_pagination_payload(request, marketplace_exchanges) + + def remove_invalid_dest_class_exchanges(self, marketplace_exchanges, nmec): + """ + Classes where the destination class the requester user will go to is not a class we are in should not be shown in exchange + """ + user_ucs_map = {uc.course_unit.id: uc for uc in list(UserCourseUnits.objects.filter(user_nmec=nmec))} + + exchanges_with_valid_dest_class = [] + for exchange in marketplace_exchanges: + for option in exchange.options: + course_unit_id = option.course_unit_id + class_issuer_goes_to = option.class_issuer_goes_to + if Class.objects.filter(course_unit_id=course_unit_id, name=class_issuer_goes_to).get().id == user_ucs_map[int(course_unit_id)].class_field.id: + exchanges_with_valid_dest_class.append(exchange) + + return exchanges_with_valid_dest_class + + def advanced_classes_filter(self, marketplace_exchanges, classes_filter): + filtered_marketplace_exchanges = [] + for exchange in marketplace_exchanges: + exchange_tainted = False + for option in exchange.options: + if option.course_unit_acronym in classes_filter.keys(): + correct_class_included = option.class_issuer_goes_from in classes_filter[option.course_unit_acronym] + exchange_tainted = not correct_class_included + + if not exchange_tainted: + filtered_marketplace_exchanges.append(exchange) + + return filtered_marketplace_exchanges + + """ + Returns all the current marketplace exchange requests paginated + """ + def get(self, request): + courseUnitNameFilter = request.query_params.get('courseUnitNameFilter', None) + classesFilter = ExchangeController.parseClassesFilter(request.query_params.get('classesFilter', None)) + + return JsonResponse(self.filterAllExchanges(request, courseUnitNameFilter.split(',') if courseUnitNameFilter else None, classesFilter), safe=False) + + def post(self, request): + return self.submit_marketplace_exchange_request(request) + + def submit_marketplace_exchange_request(self, request): + exchanges = request.POST.getlist('exchangeChoices[]') + exchanges = list(map(lambda exchange : json.loads(exchange), exchanges)) + + print("Marketplace exchange: ", exchanges) + curr_student = request.user.username + sigarra_res = SigarraController().get_student_schedule(curr_student) + + if(sigarra_res.status_code != 200): + return HttpResponse(status=sigarra_res.status_code) + + student_schedules = {} + student_schedules[curr_student] = build_student_schedule_dict(sigarra_res.data) + + student_schedule = list(student_schedules[curr_student].values()) + update_schedule_accepted_exchanges(curr_student, student_schedule) + student_schedules[curr_student] = build_student_schedule_dict(student_schedule) + + (status, new_marketplace_schedule) = build_marketplace_submission_schedule(student_schedules, exchanges, curr_student) + print("Student schedules: ", student_schedules[curr_student]) + if status == ExchangeStatus.STUDENTS_NOT_ENROLLED: + return JsonResponse({"error": incorrect_class_error()}, status=400, safe=False) + + if exchange_overlap(student_schedules, curr_student): + return JsonResponse({"error": "classes-overlap"}, status=400, safe=False) + + self.insert_marketplace_exchange(exchanges, request.user) + + return JsonResponse({"success": True}, safe=False) + + def insert_marketplace_exchange(self, exchanges, user): + issuer_name = f"{user.first_name} {user.last_name.split(' ')[-1]}" + marketplace_exchange = MarketplaceExchange.objects.create( + id=MarketplaceExchange.objects.latest("id").id + 1, + issuer_name=issuer_name, + issuer_nmec=user.username, + accepted=False + ) + for exchange in exchanges: + course_unit_id = int(exchange["courseUnitId"]) + course_unit = CourseUnit.objects.get(pk=course_unit_id) + MarketplaceExchangeClass.objects.create( + id=MarketplaceExchangeClass.objects.latest("id").id + 1, + marketplace_exchange=marketplace_exchange, + course_unit_acronym=course_unit.acronym, + course_unit_id=course_unit_id, + course_unit_name=course_unit.name, + class_issuer_goes_from=exchange["classNameRequesterGoesFrom"], + class_issuer_goes_to=exchange["classNameRequesterGoesTo"] + ) diff --git a/django/university/routes/auth/Csrf.py b/django/university/routes/auth/Csrf.py new file mode 100644 index 0000000..21cac89 --- /dev/null +++ b/django/university/routes/auth/Csrf.py @@ -0,0 +1,16 @@ +from django.views import View +from django.http.response import HttpResponse +from django.middleware.csrf import get_token + +class Csrf(View): + def get(self, request): + response = HttpResponse() + + print("csrf request cookies is: ", request.COOKIES) + + if("csrftoken" not in request.COOKIES): + cookies = request.COOKIES + response.COOKIES = cookies + response.set_cookie('csrf', get_token(request)) + + return response diff --git a/django/university/routes/auth/InfoView.py b/django/university/routes/auth/InfoView.py new file mode 100644 index 0000000..a90dd6b --- /dev/null +++ b/django/university/routes/auth/InfoView.py @@ -0,0 +1,36 @@ +from django.http import JsonResponse +from django.views import View +from django.utils import timezone +from django.http.response import HttpResponse +from django.shortcuts import redirect +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect, csrf_exempt +import requests + +from university.controllers.ExchangeController import ExchangeController +from university.controllers.SigarraController import SigarraController +from university.models import ExchangeExpirations + +class InfoView(View): + def get(self, request): + if request.user.is_authenticated: + eligible_course_units = request.session.get("eligible_course_units") + + if not eligible_course_units: + eligible_course_units = ExchangeController.eligible_course_units( + request.user.username + ) + + + eligible_exchange = bool(eligible_course_units) + + return JsonResponse({ + "signed": True, + "username": request.user.username, + "name": f"{request.user.first_name} {request.user.last_name}", + "eligible_exchange": eligible_exchange + }, safe=False) + else: + return JsonResponse({ + "signed": False + }, safe=False) diff --git a/django/university/routes/exchange/DirectExchangeView.py b/django/university/routes/exchange/DirectExchangeView.py new file mode 100644 index 0000000..bb5ce06 --- /dev/null +++ b/django/university/routes/exchange/DirectExchangeView.py @@ -0,0 +1,100 @@ +import json +import jwt +import requests +import datetime + +from django.http import HttpResponse, JsonResponse +from django.views import View +from django.utils.html import strip_tags +from django.core.mail import send_mail +from django.template.loader import render_to_string +from tts_be.settings import JWT_KEY, VERIFY_EXCHANGE_TOKEN_EXPIRATION_SECONDS, DOMAIN + +from university.controllers.StudentController import StudentController +from university.controllers.ExchangeController import ExchangeController +from university.controllers.SigarraController import SigarraController +from university.exchange.utils import ExchangeStatus, build_new_schedules, build_student_schedule_dict, build_student_schedule_dicts, incorrect_class_error, update_schedule_accepted_exchanges +from university.models import DirectExchange, DirectExchangeParticipants + +class DirectExchangeView(View): + def get(self, request): + return HttpResponse() + + def post(self, request): + student_schedules = {} + + sigarra_res = SigarraController().get_student_schedule(request.user.username) + + if (sigarra_res.status_code != 200): + return HttpResponse(status=sigarra_res.status_code) + + username = request.user.username + schedule_data = sigarra_res.data + + student_schedules[username] = build_student_schedule_dict(schedule_data) + + exchange_choices = request.POST.getlist('exchangeChoices[]') + exchanges = list(map(lambda exchange : json.loads(exchange), exchange_choices)) + + # Add the other students schedule to the dictionary + (status, trailing) = build_student_schedule_dicts(student_schedules, exchanges) + if status == ExchangeStatus.FETCH_SCHEDULE_ERROR: + return HttpResponse(status=trailing) + + for student in student_schedules.keys(): + student_schedule = list(student_schedules[student].values()) + update_schedule_accepted_exchanges(student, student_schedule) + student_schedules[student] = build_student_schedule_dict(student_schedule) + + exchange_model = DirectExchange(accepted=False, issuer_name=f"{request.user.first_name} {request.user.last_name}", issuer_nmec=request.user.username) + + (status, trailing) = build_new_schedules( + student_schedules, exchanges, request.user.username) + + if status == ExchangeStatus.STUDENTS_NOT_ENROLLED: + return JsonResponse({"error": incorrect_class_error()}, status=400, safe=False) + + inserted_exchanges = [] + (status, trailing) = ExchangeController.create_direct_exchange_participants(student_schedules, exchanges, inserted_exchanges, exchange_model, request.user.username) + + if status == ExchangeStatus.CLASSES_OVERLAP: + return JsonResponse({"error": "classes-overlap"}, status=400, safe=False) + + exchange_model.save() + + tokens_to_generate = {} + for inserted_exchange in inserted_exchanges: + participant = inserted_exchange.participant; + if not(participant in tokens_to_generate): + token = jwt.encode({"username": participant, "exchange_id": exchange_model.id, "exp": (datetime.datetime.now() + datetime.timedelta(seconds=VERIFY_EXCHANGE_TOKEN_EXPIRATION_SECONDS)).timestamp()}, JWT_KEY, algorithm="HS256") + tokens_to_generate[participant] = token + html_message = render_to_string('confirm_exchange.html', {'confirm_link': f"{DOMAIN}tts/verify_direct_exchange/{token}"}) + send_mail( + 'Confirmação de troca', + strip_tags(html_message), + 'tts@exchange.com', + [f'up{participant}@up.pt'] + ) + + inserted_exchange.save() + + return JsonResponse({"success": True}, safe=False) + + def put(self, request, id): + exchange = DirectExchange.objects.get(id=id) + + participants = DirectExchangeParticipants.objects.filter(direct_exchange=exchange) + for participant in participants: + if participant.participant_nmec == request.user.username: + participant.accepted = True + participant.save() + + participants = DirectExchangeParticipants.objects.filter(direct_exchange=exchange) + if all(participant.accepted for participant in participants): + exchange.accepted = True + + for participant in participants: + StudentController.populate_user_course_unit_data(int(participant.participant_nmec), erase_previous=True) + + + return JsonResponse({"success": True}, safe=False) diff --git a/django/university/routes/exchange/card/metadata/ExchangeCardMetadataView.py b/django/university/routes/exchange/card/metadata/ExchangeCardMetadataView.py new file mode 100644 index 0000000..342b3b8 --- /dev/null +++ b/django/university/routes/exchange/card/metadata/ExchangeCardMetadataView.py @@ -0,0 +1,32 @@ +import requests + +from rest_framework.views import APIView +from django.http import HttpResponse, JsonResponse +from django.db.models import Prefetch + +from university.controllers.ClassController import ClassController +from university.controllers.SigarraController import SigarraController +from university.models import Class + +class ExchangeCardMetadataView(APIView): + def get(self, request, course_unit_id): + """ + Returns the classes and students of a course unit + """ + try: + sigarra_res = SigarraController().get_course_unit_classes(course_unit_id) + if (sigarra_res.status_code != 200): + return HttpResponse(status=sigarra_res.status_code) + + students = sigarra_res.data + new_response = JsonResponse({ + "classes": list(ClassController.get_classes(course_unit_id)), + "students": students + }, safe=False) + + new_response.status_code = sigarra_res.status_code + + return new_response + + except requests.exceptions.RequestException as e: + return JsonResponse({"error": e}, safe=False) diff --git a/django/university/routes/exchange/export/ExchangeExportView.py b/django/university/routes/exchange/export/ExchangeExportView.py new file mode 100644 index 0000000..5745413 --- /dev/null +++ b/django/university/routes/exchange/export/ExchangeExportView.py @@ -0,0 +1,40 @@ +import csv + +from rest_framework.views import APIView +from django.http import HttpResponse + +from university.models import DirectExchange, DirectExchangeParticipants, ExchangeAdmin + +class ExchangeExportView(APIView): + def get(self, request): + if not ExchangeAdmin.objects.filter(username=request.session["username"]).exists(): + response = HttpResponse() + response.status_code = 403 + return response + + response = HttpResponse( + content_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="exchange_data.csv"'}, + ) + + writer = csv.writer(response) + writer.writerow(["student", "course_unit", "old_class", "new_class"]) + + direct_exchange_ids = DirectExchangeParticipants.objects.filter( + direct_exchange__accepted=True + ).values_list('direct_exchange', flat=True) + direct_exchanges = DirectExchange.objects.filter(id__in=direct_exchange_ids).order_by('date') + + for exchange in direct_exchanges: + participants = DirectExchangeParticipants.objects.filter(direct_exchange=exchange).order_by('date') + for participant in participants: + writer.writerow([ + participant.participant, + participant.course_unit_id, + participant.class_participant_goes_from, + participant.class_participant_goes_to + ]) + + return response + + diff --git a/django/university/routes/exchange/options/ExchangeOptionsView.py b/django/university/routes/exchange/options/ExchangeOptionsView.py new file mode 100644 index 0000000..bef7a7e --- /dev/null +++ b/django/university/routes/exchange/options/ExchangeOptionsView.py @@ -0,0 +1,17 @@ +import json +from rest_framework.views import APIView +from django.core.paginator import Paginator +from django.http import HttpResponse, JsonResponse +from django.db.models import Prefetch + +from university.exchange.utils import curr_semester_weeks +from university.models import MarketplaceExchange, MarketplaceExchangeClass +from university.serializers.MarketplaceExchangeClassSerializer import MarketplaceExchangeClassSerializer + +class ExchangeOptionsView(APIView): + """ + Returns the course units the student is enrolled in and information about them such as the enrolled students and the available classes + """ + def get(self, request): + + return HttpResponse() diff --git a/django/university/routes/exchange/verify/ExchangeVerifyView.py b/django/university/routes/exchange/verify/ExchangeVerifyView.py new file mode 100644 index 0000000..3c6242a --- /dev/null +++ b/django/university/routes/exchange/verify/ExchangeVerifyView.py @@ -0,0 +1,66 @@ +import json +import jwt +import requests +import datetime + +from django.http import HttpResponse, JsonResponse +from django.views import View +from django.utils.html import strip_tags +from django.core.mail import send_mail +from django.template.loader import render_to_string +from tts_be.settings import JWT_KEY, VERIFY_EXCHANGE_TOKEN_EXPIRATION_SECONDS, DOMAIN + +from university.controllers.StudentController import StudentController +from university.controllers.ExchangeController import ExchangeController +from university.controllers.SigarraController import SigarraController +from university.exchange.utils import ExchangeStatus, build_new_schedules, build_student_schedule_dict, build_student_schedule_dicts, curr_semester_weeks, get_student_schedule_url, incorrect_class_error, update_schedule_accepted_exchanges +from university.models import DirectExchange, MarketplaceExchange, MarketplaceExchangeClass, DirectExchangeParticipants + +class ExchangeVerifyView(View): + def post(self, response): + try: + exchange_info = jwt.decode(token, JWT_KEY, algorithms=["HS256"]) + + token_seconds_elapsed = time.time() - exchange_info["exp"] + if token_seconds_elapsed > VERIFY_EXCHANGE_TOKEN_EXPIRATION_SECONDS: + return JsonResponse({"verified": False}, safe=False, status=403) + + participant = DirectExchangeParticipants.objects.filter(participant=request.session["username"]) + participant.update(accepted=True) + + all_participants = DirectExchangeParticipants.objects.filter(direct_exchange_id=exchange_info["exchange_id"]) + + accepted_participants = 0 + for participant in all_participants: + accepted_participants += participant.accepted + + if accepted_participants == len(all_participants): + direct_exchange = DirectExchange.objects.filter(id=int(exchange_info["exchange_id"])) + direct_exchange.update(accepted=True) + + marketplace_exchange = direct_exchange.first().marketplace_exchange + + if(marketplace_exchange != None): + direct_exchange_object = direct_exchange.first() + direct_exchange_object.marketplace_exchange = None + direct_exchange_object.save() + marketplace_exchange.delete() + + for participant in all_participants: + StudentController.populate_user_course_unit_data(int(participant.participant_nmec), erase_previous=True) + + if cache.get(token) != None: + return JsonResponse({"verified": False}, safe=False, status=403) + + # Blacklist token since this token is usable only once + cache.set( + key=token, + value=token, + timeout=VERIFY_EXCHANGE_TOKEN_EXPIRATION_SECONDS - token_seconds_elapsed + ) + + return JsonResponse({"verified": True}, safe=False) + + except Exception as e: + print("Error: ", e) + return HttpResponse(status=500) \ No newline at end of file diff --git a/django/university/routes/student/StudentPhotoView.py b/django/university/routes/student/StudentPhotoView.py new file mode 100644 index 0000000..a0c9881 --- /dev/null +++ b/django/university/routes/student/StudentPhotoView.py @@ -0,0 +1,11 @@ +from django.http.response import HttpResponse, JsonResponse +from rest_framework.views import APIView + +from university.controllers.SigarraController import SigarraController + +class StudentPhotoView(APIView): + def get(self, request, nmec): + sigarra_controller = SigarraController() + photo = sigarra_controller.retrieve_student_photo(nmec) + + return HttpResponse(photo.data, content_type="image/jpeg") diff --git a/django/university/routes/student/course_units/eligible/StudentEligibleCourseUnits.py b/django/university/routes/student/course_units/eligible/StudentEligibleCourseUnits.py new file mode 100644 index 0000000..58832ff --- /dev/null +++ b/django/university/routes/student/course_units/eligible/StudentEligibleCourseUnits.py @@ -0,0 +1,19 @@ +from django.http.response import HttpResponse, JsonResponse +from rest_framework.views import APIView + +from university.controllers.ExchangeController import ExchangeController +from university.controllers.SigarraController import SigarraController +from university.models import CourseUnit + +class StudentEligibleCourseUnits(APIView): + def get(self, request): + eligible_course_units_ids = ExchangeController.eligible_course_units( + request.user.username + ) + + request.session["eligible_course_units"] = list(eligible_course_units_ids) + + eligible_course_units = CourseUnit.objects.filter(id__in=eligible_course_units_ids).values() + + return JsonResponse(list(eligible_course_units), safe=False) + diff --git a/django/university/routes/student/exchange/StudentReceivedExchangesView.py b/django/university/routes/student/exchange/StudentReceivedExchangesView.py new file mode 100644 index 0000000..97667d7 --- /dev/null +++ b/django/university/routes/student/exchange/StudentReceivedExchangesView.py @@ -0,0 +1,51 @@ +from django.core.paginator import Paginator +from django.http.response import HttpResponse, JsonResponse +from rest_framework.views import APIView +from django.db.models import Prefetch + +from university.controllers.ExchangeController import DirectExchangePendingMotive +from university.controllers.SigarraController import SigarraController +from university.models import DirectExchange, DirectExchangeParticipants +from university.serializers.DirectExchangeParticipantsSerializer import DirectExchangeParticipantsSerializer +from university.serializers.MarketplaceExchangeClassSerializer import MarketplaceExchangeClassSerializer + +class StudentReceivedExchangesView(APIView): + def get(self, request): + exchanges = DirectExchange.objects.prefetch_related( + Prefetch( + 'directexchangeparticipants_set', + queryset=DirectExchangeParticipants.objects.all(), + to_attr='options' + ) + ).filter( + directexchangeparticipants__participant_nmec=request.user.username + ).all() + + return JsonResponse(self.build_pagination_payload(request, exchanges), safe=False) + + def build_pagination_payload(self, request, exchanges): + page_number = request.GET.get("page") + paginator = Paginator(exchanges, 10) + page_obj = paginator.get_page(page_number if page_number != None else 1) + + return { + "page": { + "current": page_obj.number, + "has_next": page_obj.has_next(), + "has_previous": page_obj.has_previous(), + }, + "data": [{ + "id": exchange.id, + "type": "directexchange", + "issuer_name": exchange.issuer_name, + "issuer_nmec": exchange.issuer_nmec, + "accepted": exchange.accepted, + "pending_motive": DirectExchangePendingMotive.get_value(DirectExchangePendingMotive.get_pending_motive(request.user.username, exchange)), + "options": [ + DirectExchangeParticipantsSerializer(participant).data for participant in exchange.options + ], + "date": exchange.date + } for exchange in page_obj] + } + + diff --git a/django/university/routes/student/exchange/StudentSentExchangesView.py b/django/university/routes/student/exchange/StudentSentExchangesView.py new file mode 100644 index 0000000..f035b6a --- /dev/null +++ b/django/university/routes/student/exchange/StudentSentExchangesView.py @@ -0,0 +1,70 @@ +import base64 +import json +from django.core.paginator import Paginator +from django.db.models import Prefetch +from django.http.response import HttpResponse, JsonResponse +from rest_framework.views import APIView + +from university.controllers.ClassController import ClassController +from university.controllers.ExchangeController import ExchangeController +from university.controllers.SigarraController import SigarraController +from university.models import DirectExchange, DirectExchangeParticipants, MarketplaceExchange, MarketplaceExchangeClass +from university.serializers.MarketplaceExchangeClassSerializer import MarketplaceExchangeClassSerializer + +class StudentSentExchangesView(APIView): + def get(self, request): + course_unit_name_filter = request.query_params.get('courseUnitNameFilter', None) + + marketplace_exchanges = list(MarketplaceExchange.objects.prefetch_related( + Prefetch( + 'marketplaceexchangeclass_set', + queryset=MarketplaceExchangeClass.objects.all(), + to_attr='options' + ) + ).filter(issuer_nmec=request.user.username).all()) + + direct_exchanges = list(DirectExchange.objects.prefetch_related( + Prefetch( + 'directexchangeparticipants_set', + queryset=DirectExchangeParticipants.objects.all(), + to_attr='options' + ) + ).filter( + directexchangeparticipants__participant_nmec=request.user.username + ).all()) + + exchanges = marketplace_exchanges + direct_exchanges + # exchanges = sorted(exchanges, key=lambda request: request.date) + + if course_unit_name_filter: + marketplace_exchanges = list(filter( + lambda x: ExchangeController.courseUnitNameFilterInExchangeOptions(x.options, course_unit_name_filter), + exchanges + )) + + return JsonResponse(self.build_pagination_payload(request, marketplace_exchanges), safe=False) + + def build_pagination_payload(self, request, exchanges): + page_number = request.GET.get("page") + paginator = Paginator(exchanges, 10) + page_obj = paginator.get_page(page_number if page_number != None else 1) + + for exchange in page_obj: + print(type(exchange) == MarketplaceExchange) + + return { + "page": { + "current": page_obj.number, + "has_next": page_obj.has_next(), + "has_previous": page_obj.has_previous(), + }, + "data": [{ + "id": exchange.id, + "type": ExchangeController.getExchangeType(exchange).toString(), + "issuer_name": exchange.issuer_name, + "issuer_nmec": exchange.issuer_nmec, + "accepted": exchange.accepted, + "options": ExchangeController.getOptionsDependinOnExchangeType(exchange), + "date": exchange.date + } for exchange in page_obj] + } diff --git a/django/university/routes/student/schedule/StudentScheduleView.py b/django/university/routes/student/schedule/StudentScheduleView.py new file mode 100644 index 0000000..28903b4 --- /dev/null +++ b/django/university/routes/student/schedule/StudentScheduleView.py @@ -0,0 +1,54 @@ +import hashlib +from django.http import Http404 +from django.http.response import HttpResponse, JsonResponse, json +import requests +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from university.controllers.SigarraController import SigarraController +from university.exchange.utils import convert_sigarra_schedule, curr_semester_weeks, get_student_schedule_url, update_schedule_accepted_exchanges + +class StudentScheduleView(APIView): + def get(self, request): + sigarra_controller = SigarraController() + + try: + sigarra_res = sigarra_controller.get_student_schedule(request.user.username) + + if sigarra_res.status_code != 200: + return HttpResponse(status=sigarra_res.status_code) + + schedule_data = sigarra_res.data + old_schedule = hashlib.sha256(json.dumps(schedule_data, sort_keys=True).encode()).hexdigest() + + update_schedule_accepted_exchanges(request.user.username, schedule_data) + + new_schedule = hashlib.sha256(json.dumps(schedule_data, sort_keys=True).encode()).hexdigest() + sigarra_synchronized = old_schedule == new_schedule + + new_response = JsonResponse({"schedule": convert_sigarra_schedule(schedule_data), "noChanges": sigarra_synchronized}, safe=False) + new_response.status_code = sigarra_res.status_code + + return new_response + + except requests.exceptions.RequestException as e: + return JsonResponse({"error": e}, safe=False) + + @staticmethod + def retrieveCourseUnitClasses(sigarra_controller, username): + sigarra_res = sigarra_controller.get_student_schedule(username) + + if sigarra_res.status_code != 200: + return HttpResponse(status=sigarra_res.status_code) + + schedule_data = sigarra_res.data + + update_schedule_accepted_exchanges(username, schedule_data) + + course_unit_classes = set() + for scheduleItem in schedule_data: + course_unit_classes.add((scheduleItem["ocorrencia_id"], scheduleItem["turma_sigla"])) + + return course_unit_classes + diff --git a/django/university/serializers/DirectExchangeParticipantsSerializer.py b/django/university/serializers/DirectExchangeParticipantsSerializer.py new file mode 100644 index 0000000..c05ccc9 --- /dev/null +++ b/django/university/serializers/DirectExchangeParticipantsSerializer.py @@ -0,0 +1,45 @@ +from django.forms.models import model_to_dict +from rest_framework import serializers + +from university.models import CourseUnit +from university.controllers.ClassController import ClassController + +class DirectExchangeParticipantsSerializer(serializers.Serializer): + course_info = serializers.SerializerMethodField() + participant_name = serializers.CharField(max_length=32) + participant_nmec = serializers.CharField(max_length=32) + class_participant_goes_from = serializers.SerializerMethodField() + class_participant_goes_to = serializers.SerializerMethodField() + course_unit = serializers.CharField(max_length=64) + course_unit_id = serializers.CharField(max_length=16) + accepted = serializers.BooleanField() + date = serializers.DateTimeField() + + def get_course_info(self, obj): + course_unit_id = obj.course_unit_id + + try: + return model_to_dict(CourseUnit.objects.get(pk=course_unit_id)) + except: + return None + + + def get_class_participant_goes_from(self, obj): + class_issuer_id = obj.class_participant_goes_from + classes = ClassController.get_classes(obj.course_unit_id) + filtered_classes = list(filter(lambda x: x['name'] == class_issuer_id, classes)) + + try: + return filtered_classes[0] + except: + return None + + def get_class_participant_goes_to(self, obj): + class_issuer_id = obj.class_participant_goes_to + classes = ClassController.get_classes(obj.course_unit_id) + filtered_classes = list(filter(lambda x: x['name'] == class_issuer_id, classes)) + + try: + return filtered_classes[0] + except: + return None diff --git a/django/university/serializers/MarketplaceExchangeClassSerializer.py b/django/university/serializers/MarketplaceExchangeClassSerializer.py new file mode 100644 index 0000000..dc64239 --- /dev/null +++ b/django/university/serializers/MarketplaceExchangeClassSerializer.py @@ -0,0 +1,36 @@ +from django.forms.models import model_to_dict +from rest_framework import serializers + +from university.models import CourseUnit +from university.controllers.ClassController import ClassController + +class MarketplaceExchangeClassSerializer(serializers.Serializer): + course_info = serializers.SerializerMethodField() + class_issuer_goes_from = serializers.SerializerMethodField() + class_issuer_goes_to = serializers.SerializerMethodField() + + def get_course_info(self, obj): + course_unit_id = obj.course_unit_id + + try: + return model_to_dict(CourseUnit.objects.get(pk=course_unit_id)) + except: + return None + + def get_class_issuer_goes_from(self, obj): + class_issuer_id = obj.class_issuer_goes_from + classes = ClassController.get_classes(obj.course_unit_id) + filtered_classes = list(filter(lambda x: x['name'] == class_issuer_id, classes)) + try: + return filtered_classes[0] + except: + return None + + def get_class_issuer_goes_to(self, obj): + class_issuer_id = obj.class_issuer_goes_to + classes = ClassController.get_classes(obj.course_unit_id) + filtered_classes = list(filter(lambda x: x['name'] == class_issuer_id, classes)) + try: + return filtered_classes[0] + except: + return None \ No newline at end of file diff --git a/django/university/urls.py b/django/university/urls.py index 9e216ca..bf9c644 100644 --- a/django/university/urls.py +++ b/django/university/urls.py @@ -1,15 +1,47 @@ -from django.urls import path -from . import views +from django.urls import path, include + +from university.routes.MarketplaceExchangeView import MarketplaceExchangeView +from university.routes.auth.Csrf import Csrf +from university.routes.exchange.DirectExchangeView import DirectExchangeView +from university.routes.exchange.export.ExchangeExportView import ExchangeExportView +from university.routes.exchange.options.ExchangeOptionsView import ExchangeOptionsView +from university.routes.student.course_units.eligible.StudentEligibleCourseUnits import StudentEligibleCourseUnits +from university.routes.student.exchange.StudentReceivedExchangesView import StudentReceivedExchangesView +from university.routes.student.exchange.StudentSentExchangesView import StudentSentExchangesView +from university.routes.student.schedule.StudentScheduleView import StudentScheduleView +from university.routes.auth.InfoView import InfoView +from university.routes.student.StudentPhotoView import StudentPhotoView +from university.routes.exchange.card.metadata.ExchangeCardMetadataView import ExchangeCardMetadataView +from university.routes.exchange.verify.ExchangeVerifyView import ExchangeVerifyView +from . import views +from mozilla_django_oidc import views as oidc_views # URLConf urlpatterns = [ + path('emailtest/', views.emailtest), path('faculty/', views.faculty), path('course/