From e942ce205ecfde6ca19d710925d3c7a0a0d93c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B3nimo=20Mendes?= <39437433+JeronimoMendes@users.noreply.github.com> Date: Tue, 1 Nov 2022 02:31:21 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=94=96=20Release=20version=201.1.0=20?= =?UTF-8?q?(#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add resolution field to question (#243) * Add resolution field * Add resolution field to serializer * Adapt endpoint * Add resolution to submission page * Remove print * Add resource system (#251) * Create `Resource` model * Create `Resource` serializer * Create POST endpoint * Add DELETE endpoint * Add permission class * Fix overall windows unresponsiveness (#253) * Added some Font responsiveness to the Landing page * Deleted redudant information * Created Responsive Functions * Altered the Navbar and LandingPage margin structure * Revisioned Margin structure of Navbar * Restructured HomePage Icons Dims * Made settings page height responsive * Finalise Settings page restructure * Finalised Login page Restructuring * Regsiter Page responsiveness restructuring * Made Final Adjustements * Readjust the max fontsize of the homebuttons * Made Font size more reposnive in Exams * Fixed the maxWidth parameter to correct unaligned arrows in ExamPage * Implement questions report system (#252) * Report model created * Report model created (maybe functional) * Noob mistakes solved * Fix some errors * Acess blocked for non-admins Non-admins can't access nor delete reports, only create them * Small changes * Removed unused import * Bump django from 3.2.13 to 3.2.14 in /backend (#256) Bumps [django](https://github.com/django/django) from 3.2.13 to 3.2.14. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.2.13...3.2.14) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pillow from 9.1.0 to 9.1.1 in /backend (#231) Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.1.0 to 9.1.1. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.1.0...9.1.1) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump terser from 5.13.1 to 5.14.2 in /frontend (#257) Bumps [terser](https://github.com/terser/terser) from 5.13.1 to 5.14.2. - [Release notes](https://github.com/terser/terser/releases) - [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md) - [Commits](https://github.com/terser/terser/commits) --- updated-dependencies: - dependency-name: terser dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump django from 3.2.13 to 3.2.15 in /backend (#270) * Add issue templates * Bump django from 3.2.13 to 3.2.15 in /backend Bumps [django](https://github.com/django/django) from 3.2.13 to 3.2.15. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.2.13...3.2.15) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: Jerónimo Mendes <39437433+JeronimoMendes@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Add Makefile (#273) * Add issue templates * Update lint.yml * Add Makefile * Add Reports Page (#269) * Initial Enabling of ReportsPage * Changes made to the initial example * GetReports Backend initialised * Removal of console log * Default Collumn widths * Added Expanded Information per Report, made a Component for each report in the table * Serialized Foreign Key retrieving only one of the properties * Added Delete Icon * Delete Report Backend Added * Fixed Number Comparison * Added Erro Type Filtering * Added all filtering functionality * Styles the Dropdown according to new MUI5 Documentatio, simplification of style overrides * Enable hrefs to question page * Change table data display * Made only those with body show expandable icon * Separeted the ReportTable Component into 2 tider components * Created a button to alert what Question has more reports * Fixed Select in GenExamPage * Fixed Select in LeaderBoardPage * Added Corrections Co-authored-by: Jerónimo Mendes * Implement question page (#271) * Enable Question page * Add grid layout * Create new box component * Fetch Question * Add information panel * Add resolution box * Add resources panel * Create math block component * Added Basic Structure for Comment Section, css missing * Finnished Comment Section * Finished Reply Area * Make delete only a option for mods or authors * Fixing wrong Mod User expression * Responsive Fonts added with new Hooks * Made Chat and Comment Components, for factorization * Change Comment POST body * Deleted has_upvoted and has_downvoted * Pass context * Add user serializer to comment serializer * Fix typo * Connect to backend * Update on comment deletion * Update comments on comment submission * Fix typo * Fixed Horizontal Overflow due to navbar * Made TextField reset after submission * Added prop to question so that it can be unresponsive when it is not necessary * Fixed Grid Structure inconsistencies * Report Button Added * Generic Structure of the Dialog is set * Added Submission And Form * Selected Report Type CSS * Connected With Backend * Verifications and Notifications added * Fixed Verifications inconsistencies, tidier code * Small changes to responsiveness * Made the Textfield smaller and Submit button's size static * Fixed Missing Box and Missing Question ID * Enable hrefs to question page * Added Profanity control * Asked Improvements * Allow for date to be shown in each comment * Address review comments * Added Sorting capabilities to the questionArray * Transfered the Sorting functionalities to the question page * Changed the Styling of the Branch to MUIv5 * Changed Box Styling * Fixed Visual Bug in Styling of Comments * Reduced Redunduncies and made the requested changes * Reduced More styling Redundancies and added responsive Icons * Added more responsive icons * Removed Sneaky Console Logs from previous PR * Fixed ESlint bug * Missing Bracket from Commit Merge * Fix eslint * Fix code style issues with Prettier Co-authored-by: Miguel Dinis de Sousa Co-authored-by: Miguel Dinis <80652363+LordOfTheNeverThere@users.noreply.github.com> Co-authored-by: Lint Action * Fix node modules problem * Read env variables from .env file * Fix App.js and Navbar Unresponsiveness (#280) * Made Navbar minimally responsive for Mobile * Fixed Homepage Bug (Uncontrolled Width) added Box Component * Disabled Margins near root * Finding a workaround for the absence of global margins * Added Mobile Viewport restriction * making HomePage tidier and more responsive * More responsive changes * Missing Swipeble Drawer Functionality added * Made Swipeble Drawer occur in Mobile while no user is logged in * Added Detective SVG to the Drawer * Made changes to the Drawers text along with two new components to systematize our UI * Adjust AboutPage to 0 margin * Add min to heigfht of logo on the drawer * Converted About Page to MUIv5 styling * Added few new typography components and made some alterations to the previous ones * Used the new Components on the AboutPage tiding up styling also * Fixed Some Typography inconsistencies added padding * Finished AboutUs Restyling and Responsiveness * Made Navbar minimally responsive for Mobile * Fixed Homepage Bug (Uncontrolled Width) added Box Component * Disabled Margins near root * Finding a workaround for the absence of global margins * Added Mobile Viewport restriction * making HomePage tidier and more responsive * More responsive changes * Missing Swipeble Drawer Functionality added * Made Swipeble Drawer occur in Mobile while no user is logged in * Added Detective SVG to the Drawer * Made changes to the Drawers text along with two new components to systematize our UI * Adjust AboutPage to 0 margin * Add min to heigfht of logo on the drawer * Converted About Page to MUIv5 styling * Added few new typography components and made some alterations to the previous ones * Used the new Components on the AboutPage tiding up styling also * Fixed Some Typography inconsistencies added padding * Finished AboutUs Restyling and Responsiveness * Conflicts Collateral Damage solved * Reports Page No margin correction * Question Page no Margin Correction * Added the systematic typographies to question page * Correct Margin in Results Page * Correct Exam Page Margin * Renable Mobile Warning Page Functionality * Correct Margin Exam Page * Correct Leaderboard margin * Correct Margin Gen Exam Page * Correct Margins Landing Page * Fix import typo * Add default email backend * Bump oauthlib from 3.1.1 to 3.2.1 in /backend (#287) Bumps [oauthlib](https://github.com/oauthlib/oauthlib) from 3.1.1 to 3.2.1. - [Release notes](https://github.com/oauthlib/oauthlib/releases) - [Changelog](https://github.com/oauthlib/oauthlib/blob/master/CHANGELOG.rst) - [Commits](https://github.com/oauthlib/oauthlib/compare/v3.1.1...v3.2.1) --- updated-dependencies: - dependency-name: oauthlib dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Deprecate `django.conf.urls.url` and remove unused urls (#288) * Deprecation solved * Unused urls removed * Bump django from 3.2.13 to 3.2.15 in /backend (#296) Bumps [django](https://github.com/django/django) from 3.2.13 to 3.2.15. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.2.13...3.2.15) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix allowed hosts (#299) * Fix settings to allow list of allowed hosts * Update .env.example * Bump version Signed-off-by: dependabot[bot] Co-authored-by: Miguel Dinis <80652363+LordOfTheNeverThere@users.noreply.github.com> Co-authored-by: afonsofsdomingues <92863313+afonsofsdomingues@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Miguel Dinis de Sousa Co-authored-by: Lint Action Co-authored-by: pearsettings44 --- Makefile | 22 + backend/.env.example | 7 +- backend/Arquimedia/settings.py | 195 ++--- backend/Arquimedia/urls.py | 14 +- backend/api/serializer.py | 291 ++++---- backend/api/urls.py | 8 +- backend/api/views.py | 103 ++- backend/exams/admin.py | 5 +- .../migrations/0020_question_resolution.py | 18 + backend/exams/migrations/0021_resource.py | 24 + .../migrations/0022_auto_20220606_2355.py | 32 + .../migrations/0023_alter_report_body.py | 18 + .../migrations/0024_alter_comment_date.py | 18 + backend/exams/models.py | 111 ++- backend/exams/urls.py | 19 - backend/requirements.txt | 6 +- backend/users/urls.py | 11 - docker-compose.override.yml | 4 +- docker-compose.yml | 2 + frontend/.eslintrc.json | 1 + frontend/Dockerfile | 3 - frontend/package-lock.json | 181 +++-- frontend/package.json | 3 +- frontend/src/App.js | 81 +-- frontend/src/api.js | 44 +- frontend/src/assets/deleteComment.svg | 3 + frontend/src/assets/desformatIcon.svg | 3 + frontend/src/assets/detective.svg | 1 + frontend/src/assets/downvote.svg | 3 + frontend/src/assets/downvoteFilled.svg | 3 + frontend/src/assets/imageIcon.svg | 5 + frontend/src/assets/loadingIcon.svg | 14 + frontend/src/assets/messageReport.svg | 3 + frontend/src/assets/onlyBrainLogo.svg | 13 + frontend/src/assets/otherIcon.svg | 4 + frontend/src/assets/submissionIcon.svg | 3 + frontend/src/assets/textBubbleIcon.svg | 3 + frontend/src/assets/upvote.svg | 3 + frontend/src/assets/upvoteFilled.svg | 3 + frontend/src/components/Box/Box.js | 23 + frontend/src/components/Math/MathBlock.js | 13 + .../components/MenuCircular/MenuCircular.js | 5 +- frontend/src/components/box/Box.js | 23 + frontend/src/components/buttons/IconButton.js | 18 +- frontend/src/components/chat/Chat.js | 103 +++ frontend/src/components/chat/Comment.js | 198 ++++++ .../countdownClock/CountdownClock.js | 10 +- .../src/components/dialogs/ReportDialog.js | 272 +++++++ frontend/src/components/inputs/TextInput.js | 1 + frontend/src/components/login/LoginInput.js | 80 ++- frontend/src/components/navbar/Navbar.js | 281 +++++--- .../src/components/navbar/NavbarButton.js | 26 +- frontend/src/components/questions/Answers.js | 10 +- frontend/src/components/questions/Question.js | 35 +- .../components/questions/QuestionAccordion.js | 13 +- .../src/components/questions/QuestionForm.js | 36 +- .../src/components/register/RegisterInfo.js | 36 +- .../src/components/register/RegisterInput.js | 99 ++- frontend/src/components/report/Report.js | 82 +++ .../src/components/report/ReportTableHead.js | 94 +++ .../components/report/ReportTableToolbar.js | 195 +++++ .../components/subject/SubjectInfoPanel.js | 17 +- frontend/src/components/typographies/Body.js | 18 + .../components/typographies/Description.js | 19 + .../src/components/typographies/Subtitle.js | 21 + frontend/src/components/typographies/Title.js | 20 + frontend/src/globalTheme.js | 15 +- frontend/src/hooks/responsiveHeight.js | 19 + frontend/src/hooks/responsiveWidth.js | 19 + frontend/src/pages/AboutUsPage.js | 314 ++++---- frontend/src/pages/ExamPage.js | 121 ++-- frontend/src/pages/GenExamPage.js | 389 +++++----- frontend/src/pages/HomePage.js | 344 ++++----- frontend/src/pages/LandingPage.js | 85 ++- frontend/src/pages/LeaderboardPage.js | 71 +- frontend/src/pages/LoginPage.js | 35 +- frontend/src/pages/ProfilePage.js | 67 +- frontend/src/pages/QuestionPage.js | 672 ++++++++++-------- frontend/src/pages/RegistrationPage.js | 12 +- frontend/src/pages/ReportsPage.js | 180 +++++ frontend/src/pages/ResultsPage.js | 119 ++-- frontend/src/pages/SettingsPage.js | 41 +- frontend/src/utils/badWords.json | 505 +++++++++++++ frontend/src/utils/isSwear.js | 17 + frontend/src/utils/isUnique.js | 5 + frontend/src/utils/maxNumOccurences.js | 18 + 86 files changed, 4336 insertions(+), 1745 deletions(-) create mode 100644 Makefile create mode 100644 backend/exams/migrations/0020_question_resolution.py create mode 100644 backend/exams/migrations/0021_resource.py create mode 100644 backend/exams/migrations/0022_auto_20220606_2355.py create mode 100644 backend/exams/migrations/0023_alter_report_body.py create mode 100644 backend/exams/migrations/0024_alter_comment_date.py delete mode 100644 backend/exams/urls.py delete mode 100644 backend/users/urls.py create mode 100644 frontend/src/assets/deleteComment.svg create mode 100644 frontend/src/assets/desformatIcon.svg create mode 100644 frontend/src/assets/detective.svg create mode 100644 frontend/src/assets/downvote.svg create mode 100644 frontend/src/assets/downvoteFilled.svg create mode 100644 frontend/src/assets/imageIcon.svg create mode 100644 frontend/src/assets/loadingIcon.svg create mode 100644 frontend/src/assets/messageReport.svg create mode 100644 frontend/src/assets/onlyBrainLogo.svg create mode 100644 frontend/src/assets/otherIcon.svg create mode 100644 frontend/src/assets/submissionIcon.svg create mode 100644 frontend/src/assets/textBubbleIcon.svg create mode 100644 frontend/src/assets/upvote.svg create mode 100644 frontend/src/assets/upvoteFilled.svg create mode 100644 frontend/src/components/Box/Box.js create mode 100644 frontend/src/components/Math/MathBlock.js create mode 100644 frontend/src/components/box/Box.js create mode 100644 frontend/src/components/chat/Chat.js create mode 100644 frontend/src/components/chat/Comment.js create mode 100644 frontend/src/components/dialogs/ReportDialog.js create mode 100644 frontend/src/components/report/Report.js create mode 100644 frontend/src/components/report/ReportTableHead.js create mode 100644 frontend/src/components/report/ReportTableToolbar.js create mode 100644 frontend/src/components/typographies/Body.js create mode 100644 frontend/src/components/typographies/Description.js create mode 100644 frontend/src/components/typographies/Subtitle.js create mode 100644 frontend/src/components/typographies/Title.js create mode 100644 frontend/src/hooks/responsiveHeight.js create mode 100644 frontend/src/hooks/responsiveWidth.js create mode 100644 frontend/src/pages/ReportsPage.js create mode 100644 frontend/src/utils/badWords.json create mode 100644 frontend/src/utils/isSwear.js create mode 100644 frontend/src/utils/isUnique.js create mode 100644 frontend/src/utils/maxNumOccurences.js diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..84592b15 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +all: clean deploy logs + +build: + docker compose -f docker-compose.yml -f docker-compose.override.yml build + +deploy: + docker compose -f docker-compose.yml -f docker-compose.override.yml up -d + +logs: + docker compose logs -f + +logs-backend: + docker logs -f backend-dev + +logs-frontend: + docker logs -f frontend-dev + +shell-backend: + docker exec -it backend-dev /bin/bash + +clean: + docker compose -f docker-compose.yml -f docker-compose.override.yml down --remove-orphans \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 5da9d1c2..52db2b1e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,4 +3,9 @@ DATABASE_USER = DATABASE_PASSWORD = DATABASE_HOST = localhost DATABASE_PORT = 5432 -DJANGO_DEBUG = True \ No newline at end of file + +SECRET_KEY = "secret" +DJANGO_DEBUG = True + +# If multiple hosts, they should be separated by commas. (ex: "localhost,arquimedia.pt,admin.aquimedia.pt") +ALLOWED_HOSTS = "localhost" diff --git a/backend/Arquimedia/settings.py b/backend/Arquimedia/settings.py index e4888167..2b1a2acb 100644 --- a/backend/Arquimedia/settings.py +++ b/backend/Arquimedia/settings.py @@ -14,6 +14,7 @@ import os from dotenv import load_dotenv from celery.schedules import crontab + load_dotenv() SITE_ID = 1 @@ -29,86 +30,92 @@ SECRET_KEY = os.getenv("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = (os.getenv("DJANGO_DEBUG", True) == "True") +DEBUG = os.getenv("DJANGO_DEBUG", True) == "True" -ALLOWED_HOSTS = [os.getenv("ALLOWED_HOSTS")] +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS").split(",") +print(type(ALLOWED_HOSTS)) +print(ALLOWED_HOSTS) # Application definition INSTALLED_APPS = [ - 'corsheaders', - 'users', - 'exams', - 'api', - 'rest_framework', - 'rest_framework.authtoken', - 'rest_auth', - 'django.contrib.sites', - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'rest_auth.registration', - 'django_celery_beat', - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles' + "corsheaders", + "users", + "exams", + "api", + "rest_framework", + "rest_framework.authtoken", + "rest_auth", + "django.contrib.sites", + "allauth", + "allauth.account", + "allauth.socialaccount", + "rest_auth.registration", + "django_celery_beat", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", ] -CORS_ALLOWED_ORIGINS = ["http://localhost:3000", "https://arquimedia.pt", "http://localhost" ] +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "https://arquimedia.pt", + "http://localhost", +] CORS_ALLOW_CREDENTIALS = True MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'Arquimedia.urls' +ROOT_URLCONF = "Arquimedia.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ # insert your TEMPLATE_DIRS here - os.path.join(BASE_DIR, 'Arquimedia/templates'), + os.path.join(BASE_DIR, "Arquimedia/templates"), ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'Arquimedia.wsgi.application' +WSGI_APPLICATION = "Arquimedia.wsgi.application" # Database # https://docs.djangoproject.com/en/3.1/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': os.getenv("DATABASE_NAME"), - 'USER': os.getenv("DATABASE_USER"), - 'PASSWORD': os.getenv("DATABASE_PASSWORD"), - 'HOST': os.getenv("DATABASE_HOST"), - 'PORT': os.getenv("DATABASE_PORT"), + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": os.getenv("DATABASE_NAME"), + "USER": os.getenv("DATABASE_USER"), + "PASSWORD": os.getenv("DATABASE_PASSWORD"), + "HOST": os.getenv("DATABASE_HOST"), + "PORT": os.getenv("DATABASE_PORT"), } } @@ -118,29 +125,33 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] -REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.TokenAuthentication',],} +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.TokenAuthentication", + ], +} # Internationalization # https://docs.djangoproject.com/en/3.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -152,16 +163,14 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.1/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" -STATICFILES_DIRS = ( - os.path.join(BASE_DIR, 'static'), -) +STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # Login and logout redirect -LOGIN_REDIRECT_URL = '/' -LOGOUT_REDIRECT_URL = '/' -#Login Path for Login required Decorators -LOGIN_URL = '^login/$' +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/" +# Login Path for Login required Decorators +LOGIN_URL = "^login/$" # Disables the verification email sent by allauth ACCOUNT_EMAIL_VERIFICATION = "none" @@ -169,7 +178,9 @@ OLD_PASSWORD_FIELD_ENABLED = True # SMTP Settings -EMAIL_BACKEND = os.getenv('EMAIL_BACKEND') +EMAIL_BACKEND = os.getenv( + "EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend" +) EMAIL_USE_TLS = True EMAIL_HOST = os.getenv("SMTP_HOST") EMAIL_PORT = os.getenv("SMTP_PORT") @@ -180,15 +191,15 @@ MEDIA_ROOT = os.path.join(BASE_DIR, "static/images") MEDIA_URL = "/images/" -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Celery settings BROKER_URL = os.getenv("REDIS_URL") CELERY_RESULT_BACKEND = os.getenv("REDIS_URL") -CELERY_ACCEPT_CONTENT = ['application/json'] -CELERY_TASK_SERIALIZER = 'json' -CELERY_RESULT_SERIALIZER = 'json' -CELERY_TIMEZONE = 'Europe/Lisbon' +CELERY_ACCEPT_CONTENT = ["application/json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = "Europe/Lisbon" # Logging Configuration @@ -196,26 +207,30 @@ LOGGING_CONFIG = None # Get loglevel from env -LOGLEVEL = os.getenv('DJANGO_LOGLEVEL', 'info').upper() - -logging.config.dictConfig({ - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'console': { - 'format': '%(asctime)s %(levelname)s [%(name)s:%(lineno)s] %(module)s %(process)d %(thread)d %(message)s', +LOGLEVEL = os.getenv("DJANGO_LOGLEVEL", "info").upper() + +logging.config.dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "console": { + "format": "%(asctime)s %(levelname)s [%(name)s:%(lineno)s] %(module)s %(process)d %(thread)d %(message)s", + }, }, - }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'console', + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "console", + }, }, - }, - 'loggers': { - '': { - 'level': LOGLEVEL, - 'handlers': ['console',], + "loggers": { + "": { + "level": LOGLEVEL, + "handlers": [ + "console", + ], + }, }, - }, -}) \ No newline at end of file + } +) diff --git a/backend/Arquimedia/urls.py b/backend/Arquimedia/urls.py index 54a686e9..db5ea549 100644 --- a/backend/Arquimedia/urls.py +++ b/backend/Arquimedia/urls.py @@ -15,7 +15,7 @@ """ from django.contrib import admin from django.urls import path, re_path -from django.conf.urls import url, include +from django.conf.urls import include from django.contrib.auth import views as auth_views from .views import CustomLoginView, index, CustomRegisterView from django.conf import settings @@ -25,16 +25,14 @@ from api.views import VerifyEmailView urlpatterns = [ - path('', include("users.urls")), path('admin/', admin.site.urls), - url(r'^$', index), - url('exame/', include('exams.urls')), + re_path(r'^$', index), path("api/", include("api.urls")), - url(r'^rest-auth/login/', CustomLoginView.as_view()), - url(r'^rest-auth/registration/', CustomRegisterView.as_view()), - url(r'^rest-auth/', include('rest_auth.urls')), + re_path(r'^rest-auth/login/', CustomLoginView.as_view()), + re_path(r'^rest-auth/registration/', CustomRegisterView.as_view()), + re_path(r'^rest-auth/', include('rest_auth.urls')), path('rest-auth/password/reset/confirm///', views.PasswordResetConfirmView.as_view(), name="password_reset_confirm"), - url(r'^rest-auth/registration/', include('rest_auth.registration.urls')), + re_path(r'^rest-auth/registration/', include('rest_auth.registration.urls')), ] # Allows to fetch images diff --git a/backend/api/serializer.py b/backend/api/serializer.py index 387e6e20..bfd24f3c 100644 --- a/backend/api/serializer.py +++ b/backend/api/serializer.py @@ -1,206 +1,259 @@ +from exams.models import Report from users.models import Achievement, AnswerInfo, Profile, SubjectInfo, XPEvent, XPSystem from django.db.models import fields from rest_framework.fields import ReadOnlyField -from exams.models import Question, Comment, Exam, Answer -from django.contrib.auth.models import User +from exams.models import * +from django.contrib.auth.models import User from rest_framework import serializers from config import subjects from rest_framework.fields import CurrentUserDefault import os SUBJECT_CHOICES = [(i['name'], i['name']) for i in subjects if i['active']] + + class UserSerializer(serializers.ModelSerializer): - id = serializers.SlugField() - username = serializers.ReadOnlyField() - mod = serializers.BooleanField(source="is_staff") - admin = serializers.BooleanField(source="is_superuser") - profile = serializers.IntegerField(source='profile.id') + id = serializers.SlugField() + username = serializers.ReadOnlyField() + mod = serializers.BooleanField(source="is_staff") + admin = serializers.BooleanField(source="is_superuser") + profile = serializers.IntegerField(source='profile.id') - class Meta: - model = User - fields = ("id", "profile", "username", "email", "mod", "admin") + class Meta: + model = User + fields = ("id", "profile", "username", "email", "mod", "admin") + + def get_profile(self): + profile = Profile.objects.get(user=self.id) - def get_profile(self): - profile = Profile.objects.get(user=self.id) class XPEventSerializer(serializers.ModelSerializer): - class Meta: - model = XPEvent - fields = ("date", "amount") + class Meta: + model = XPEvent + fields = ("date", "amount") class QuestionShortSerializer(serializers.ModelSerializer): - id = serializers.SlugField() + id = serializers.SlugField() - class Meta: - model = Question - fields = ("id", ) + class Meta: + model = Question + fields = ("id", ) class CommentSerializer(serializers.ModelSerializer): - author = UserSerializer(many=False) - question = QuestionShortSerializer(many=False) + voted = serializers.SerializerMethodField() + author = UserSerializer() + class Meta: + model = Comment + fields = ("id", "content", "author", "votes", + "voted", "date", "question") - class Meta: - model = Comment - fields = ("id", "content", "author", "votes", "date", "question") + def get_voted(self, obj): + current_user = self.context.user + comment = obj + + if current_user in comment.upvoters.all(): + return 1 + + if current_user in comment.downvoters.all(): + return -1 + + return 0 - def create(self, validated_data): - content = validated_data["content"] - author = User.objects.get(id=validated_data["author"]["id"]) - question = Question.objects.get(id=validated_data["question"]["id"]) - comment = Comment.objects.create( - content=content, - author=author, - question=question - ) - return comment + +class CommentCreateSerializer(serializers.Serializer): + content = serializers.CharField() + question = serializers.IntegerField() class CommentVoteChangeSerializer(serializers.ModelSerializer): - class Meta: - model = Comment - fields = ("votes", ) + class Meta: + model = Comment + fields = ("votes", ) class AnswerSerializer(serializers.ModelSerializer): - class Meta: - model = Answer - fields = ("id", "text", "correct") + class Meta: + model = Answer + fields = ("id", "text", "correct") + + +class ResourceSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + + class Meta: + model = Resource + fields = ("id", "description", "url", "type") class QuestionSerializer(serializers.ModelSerializer): - comment = CommentSerializer(many=True, read_only=True) - answer = AnswerSerializer(many=True) - image = serializers.SerializerMethodField() - - class Meta: - model = Question - fields = ("id", "text", "subject", "subsubject", "year", "difficulty", "comment", "answer", "image", "source", "date") + comment = CommentSerializer(many=True, read_only=True) + answer = AnswerSerializer(many=True) + image = serializers.SerializerMethodField() + resources = ResourceSerializer(many=True) + + class Meta: + model = Question + fields = ("id", "text", "resolution", "subject", "subsubject", "year", + "difficulty", "comment", "answer", "image", "source", "date", "resources") - def getAnswers(self, question): - return [answer for answer in question.answer.all] + def getAnswers(self, question): + return [answer for answer in question.answer.all] - def get_image(self, obj): - address = os.getenv("ALLOWED_HOST", "localhost:" + str(os.getenv("DJANGO_PORT", 8000))) + def get_image(self, obj): + address = os.getenv("ALLOWED_HOST", "localhost:" + + str(os.getenv("DJANGO_PORT", 8000))) - # cursed - if os.getenv("DJANGO_DEBUG") == "False": - address += "/api" + # cursed + if os.getenv("DJANGO_DEBUG") == "False": + address += "/api" - if str(obj.image): - return "http://" + address + "/images/" + str(obj.image) + if str(obj.image): + return "http://" + address + "/images/" + str(obj.image) + + return None - return None class ExamSerializer(serializers.ModelSerializer): - questions = QuestionSerializer(many=True) - failed = QuestionSerializer(many=True) - correct = QuestionSerializer(many=True) + questions = QuestionSerializer(many=True) + failed = QuestionSerializer(many=True) + correct = QuestionSerializer(many=True) + + class Meta: + model = Exam + fields = ("id", "questions", "failed", "correct", + "score", "subject", "year", "difficulty") - class Meta: - model = Exam - fields = ("id", "questions", "failed", "correct", "score", "subject", "year", "difficulty") class CreateExamSerializer(serializers.Serializer): - subject = serializers.ChoiceField(choices=SUBJECT_CHOICES) - subSubjects = serializers.ListField(child = serializers.CharField()) - year = serializers.ListField(child = serializers.IntegerField()) - randomSubSubject = serializers.BooleanField() + subject = serializers.ChoiceField(choices=SUBJECT_CHOICES) + subSubjects = serializers.ListField(child=serializers.CharField()) + year = serializers.ListField(child=serializers.IntegerField()) + randomSubSubject = serializers.BooleanField() + class CreateRecommendedExamSerializer(serializers.Serializer): - subject = serializers.ChoiceField(choices=SUBJECT_CHOICES) + subject = serializers.ChoiceField(choices=SUBJECT_CHOICES) class AchievementSerializer(serializers.ModelSerializer): - class Meta: - model = Achievement - fields = "__all__" + class Meta: + model = Achievement + fields = "__all__" class XPSerializer(serializers.ModelSerializer): - class Meta: - model = XPSystem - fields = ("xp", "currentLevel", "levelXP") + class Meta: + model = XPSystem + fields = ("xp", "currentLevel", "levelXP") class answersInfoSerializer(serializers.ModelSerializer): - answer = QuestionShortSerializer() - class Meta: - model = AnswerInfo - fields = "__all__" + answer = QuestionShortSerializer() + + class Meta: + model = AnswerInfo + fields = "__all__" class SubjectSerializer(serializers.ModelSerializer): - wrongAnswers = answersInfoSerializer(many=True) - correctAnswers = answersInfoSerializer(many=True) + wrongAnswers = answersInfoSerializer(many=True) + correctAnswers = answersInfoSerializer(many=True) - class Meta: - model = SubjectInfo - fields = "__all__" + class Meta: + model = SubjectInfo + fields = "__all__" class ProfileSerializer(serializers.ModelSerializer): - achievements = AchievementSerializer(many=True) - xp = XPSerializer() - user = UserSerializer() - subjects = SubjectSerializer(many=True) - follows = UserSerializer(many=True) + achievements = AchievementSerializer(many=True) + xp = XPSerializer() + user = UserSerializer() + subjects = SubjectSerializer(many=True) + follows = UserSerializer(many=True) - class Meta: - model = Profile - fields = ("user", "subjects", "xp", "achievements", "follows", "streak", "last_activity") + class Meta: + model = Profile + fields = ("user", "subjects", "xp", "achievements", + "follows", "streak", "last_activity") class AnswerSubmitionSerializer(serializers.Serializer): - text = serializers.CharField() - correct = serializers.BooleanField() + text = serializers.CharField() + correct = serializers.BooleanField() class CreateQuestionSerializer(serializers.Serializer): - text = serializers.CharField() - subsubject = serializers.CharField() - subject = serializers.CharField() - year = serializers.IntegerField() - answers = serializers.ListField(child=AnswerSerializer()) - source = serializers.CharField(required=False, allow_blank=True) + text = serializers.CharField() + resolution = serializers.CharField(required=False, allow_blank=True) + subsubject = serializers.CharField() + subject = serializers.CharField() + year = serializers.IntegerField() + answers = serializers.ListField(child=AnswerSerializer()) + source = serializers.CharField(required=False, allow_blank=True) class ImageSerializer(serializers.Serializer): - image = serializers.ImageField() + image = serializers.ImageField() class SubjectInfoSerializer(serializers.Serializer): - subject = serializers.CharField() - questions = QuestionShortSerializer(many=True) + subject = serializers.CharField() + questions = QuestionShortSerializer(many=True) class ProfileLeaderboardSerializer(serializers.ModelSerializer): - xp = XPSerializer() - class Meta: - model = Profile - fields = ("id", "xp") + xp = XPSerializer() + + class Meta: + model = Profile + fields = ("id", "xp") + class ProfileLeaderboardTimedSerializer(serializers.Serializer): - id = serializers.IntegerField() - xp = serializers.IntegerField() + id = serializers.IntegerField() + xp = serializers.IntegerField() + class LeaderboardSerializer(serializers.Serializer): - users = ProfileLeaderboardTimedSerializer(many=True) - length = serializers.IntegerField() + users = ProfileLeaderboardTimedSerializer(many=True) + length = serializers.IntegerField() + class DeleteAccountSerializer(serializers.Serializer): - password = serializers.CharField(style={'input_type': 'password'}) + password = serializers.CharField(style={'input_type': 'password'}) + + def validate(self, attrs): + password = attrs.get('password') + user = self.context.get("request").user + + if not user.check_password(password): + err_msg = ( + "Your old password was entered incorrectly. Please enter it again.") + raise serializers.ValidationError(err_msg) + + user.delete() + + return password + + +class ReportSerializer(serializers.ModelSerializer): + + id = serializers.SlugField(read_only=True) + date = serializers.DateTimeField(read_only=True) + author = serializers.CharField(source='author.username', read_only=True) + class Meta: + model = Report + fields = ['id', 'question', 'date', 'type', 'body', 'author'] - def validate(self, attrs): - password = attrs.get('password') - user = self.context.get("request").user - if not user.check_password(password): - err_msg = ("Your old password was entered incorrectly. Please enter it again.") - raise serializers.ValidationError(err_msg) +class CreateReportSerializer(serializers.ModelSerializer): + body = serializers.CharField( + required=False, allow_blank=True) - user.delete() + class Meta: + model = Report + fields = ['question', 'type', 'body'] - return password \ No newline at end of file diff --git a/backend/api/urls.py b/backend/api/urls.py index 68fcb3c9..4bac5478 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -4,14 +4,13 @@ urlpatterns = [ path("questions", QuestionsListView.as_view()), path("question/", QuestionView.as_view()), + path("question/resource/", ResourceView.as_view()), path("question/", QuestionView.as_view()), path("comment/", CommentView.as_view()), path("comment/", CommentView.as_view()), path("current_user", CurrentUserView.as_view()), path("upvote/", UpvoteCommentView.as_view()), path("downvote/", DownvoteCommentView.as_view()), - path("has_upvoted/", HasUserUpvoted.as_view()), - path("has_downvoted/", HasUserDownvoted.as_view()), path("exam/", ExamView.as_view()), path("exam/", ExamView.as_view()), path("exam/recommended/", RecommendedExamView.as_view()), @@ -26,5 +25,8 @@ path("follow/", Follow.as_view()), path("email-confirm//", VerifyEmailView.as_view()), path("users/", Users.as_view()), - path("user/", DeleteAccount.as_view()) + path("user/", DeleteAccount.as_view()), + path("reports/", ReportListView.as_view()), + path("report/", ReportView.as_view()), + path("report/", ReportView.as_view()) ] diff --git a/backend/api/views.py b/backend/api/views.py index 6e7fb62f..05940bac 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -10,7 +10,7 @@ import random from rest_framework.parsers import FileUploadParser, MultiPartParser, FormParser, JSONParser from django.shortcuts import get_object_or_404, get_list_or_404 -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import IsAuthenticated, AllowAny, IsAdminUser import datetime from rest_auth.registration.serializers import VerifyEmailSerializer from allauth.account.models import EmailAddress @@ -35,7 +35,7 @@ class QuestionView(APIView): def get(self, request, id): question = get_object_or_404(Question, id=id) - return Response(QuestionSerializer(question).data, status=status.HTTP_200_OK) + return Response(QuestionSerializer(question, context=request).data, status=status.HTTP_200_OK) # Validates a question @@ -75,6 +75,7 @@ def post(self, request): newQuestion.source = question.data.get("source") newQuestion.text = question.data.get("text") + newQuestion.resolution = question.data.get("resolution") newQuestion.subject = question.data.get("subject") newQuestion.subsubject = question.data.get("subsubject") newQuestion.year = question.data.get("year") @@ -124,20 +125,20 @@ def get(self, request, id): comment = get_object_or_404(Comment, id=id) - return Response(self.serializer_class(comment).data, status=status.HTTP_200_OK) + return Response(self.serializer_class(comment, context=request).data, status=status.HTTP_200_OK) def post(self, request): if not self.request.user.is_authenticated: return Response({"Bad Request": "User not logged in..."}, status=status.HTTP_400_BAD_REQUEST) - serializer = self.serializer_class(data=request.data) + serializer = CommentCreateSerializer(data=request.data) if serializer.is_valid(): content = serializer.data.get("content") - question = Question.objects.get(id=serializer.data.get("question")["id"]) + question = Question.objects.get(id=serializer.data.get("question")) comment = Comment(author=self.request.user, question=question, content=content) comment.save() - return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED) + return Response(CommentSerializer(comment, context=request).data, status=status.HTTP_201_CREATED) return Response({"Bad Request": "Invalid data..."}, status=status.HTTP_400_BAD_REQUEST) @@ -171,7 +172,7 @@ def post(self, request, id): comment.save() - return Response(self.serializer_class(comment).data, status=status.HTTP_200_OK) + return Response("Comment upvoted!", status=status.HTTP_200_OK) # Removes an upvote from a Comment def delete(self, request, id): @@ -184,7 +185,7 @@ def delete(self, request, id): comment.upvoters.remove(request.user) comment.save() - return Response(self.serializer_class(comment).data, status=status.HTTP_200_OK) + return Response("Removed upvote!", status=status.HTTP_200_OK) @@ -207,7 +208,7 @@ def post(self, request, id): comment.downvoters.add(request.user) comment.save() - return Response(self.serializer_class(comment).data, status=status.HTTP_200_OK) + return Response("Comment downvoted!", status=status.HTTP_200_OK) # Removes a downvote from a Comment def delete(self, request, id): @@ -220,29 +221,7 @@ def delete(self, request, id): comment.downvoters.remove(request.user) comment.save() - return Response(self.serializer_class(comment).data, status=status.HTTP_200_OK) - - -class HasUserUpvoted(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request, *args, **kwargs): - comment = Comment.objects.get(id=kwargs.get("id")) - if request.user in comment.upvoters.all(): - return Response(status=status.HTTP_200_OK) - - else: return Response(status=status.HTTP_400_BAD_REQUEST) - - -class HasUserDownvoted(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request, *args, **kwargs): - comment = Comment.objects.get(id=kwargs.get("id")) - if request.user in comment.downvoters.all(): - return Response(status=status.HTTP_200_OK) - - else: return Response(status=status.HTTP_400_BAD_REQUEST) + return Response("Removed downvote", status=status.HTTP_200_OK) class ExamView(APIView): @@ -574,3 +553,63 @@ def delete(self, request, *args, **kwargs): status=status.HTTP_200_OK ) +class ResourceView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request, id): + serializer = ResourceSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + resource = Resource.objects.create( + description=serializer.data.get("description"), + url=serializer.data.get("url"), + type=serializer.data.get("type"), + question=get_object_or_404(Question, id=id) + ) + + return Response(ResourceSerializer(resource).data, status=status.HTTP_201_CREATED) + + def delete(self, request, id): + resource = get_object_or_404(Resource, id=id) + resource.delete() + + return Response(status=status.HTTP_200_OK) + +class ReportListView(generics.ListAPIView): + permission_classes = [IsAdminUser] + + queryset = Report.objects.all() + serializer_class = ReportSerializer + +class ReportView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ReportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + report = Report.objects.create( + question = get_object_or_404(Question, id=serializer.data.get("question")), + author = request.user, + type = serializer.data.get("type"), + body = serializer.data.get("body") + ) + + return Response(ReportSerializer(report).data, status=status.HTTP_201_CREATED) + + def delete(self, request, id): + if not(request.user.is_staff): + return Response(status=status.HTTP_403_FORBIDDEN) + + report = get_object_or_404(Report, id=id) + report.delete() + + return Response(status=status.HTTP_200_OK) + + def get(self, request, id): + if not(request.user.is_staff): + return Response(status=status.HTTP_403_FORBIDDEN) + + report = get_object_or_404(Report, id=id) + + return Response(ReportSerializer(report).data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/exams/admin.py b/backend/exams/admin.py index 37d7a317..77ccf2af 100644 --- a/backend/exams/admin.py +++ b/backend/exams/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin -from .models import Exam, Question, Answer +from .models import Exam, Question, Answer, Report # Register your models here. admin.site.register(Exam) admin.site.register(Question) -admin.site.register(Answer) \ No newline at end of file +admin.site.register(Answer) +admin.site.register(Report) \ No newline at end of file diff --git a/backend/exams/migrations/0020_question_resolution.py b/backend/exams/migrations/0020_question_resolution.py new file mode 100644 index 00000000..112ed5ca --- /dev/null +++ b/backend/exams/migrations/0020_question_resolution.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-03 22:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0019_auto_20220429_0051'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='resolution', + field=models.TextField(null=True), + ), + ] diff --git a/backend/exams/migrations/0021_resource.py b/backend/exams/migrations/0021_resource.py new file mode 100644 index 00000000..f285fb98 --- /dev/null +++ b/backend/exams/migrations/0021_resource.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-06-04 21:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0020_question_resolution'), + ] + + operations = [ + migrations.CreateModel( + name='Resource', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('VIDEO', 'Video'), ('PAPER', 'Paper')], max_length=50)), + ('url', models.TextField()), + ('description', models.TextField()), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resources', to='exams.question')), + ], + ), + ] diff --git a/backend/exams/migrations/0022_auto_20220606_2355.py b/backend/exams/migrations/0022_auto_20220606_2355.py new file mode 100644 index 00000000..719eccb3 --- /dev/null +++ b/backend/exams/migrations/0022_auto_20220606_2355.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.5 on 2022-06-06 22:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('exams', '0021_resource'), + ] + + operations = [ + migrations.AlterField( + model_name='resource', + name='type', + field=models.CharField(choices=[('video', 'Video'), ('paper', 'Paper')], max_length=50), + ), + migrations.CreateModel( + name='Report', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateTimeField(auto_now_add=True)), + ('type', models.CharField(choices=[('Typo', 'Gralha no enunciado ou nas opções de resposta'), ('SubmissionError', 'Erro na submissão'), ('QuestionFormatting', 'Pergunta desformatada'), ('LoadingError', 'Página não carrega'), ('ImageError', 'Figura errada ou em falta'), ('Other', 'Outro')], max_length=50)), + ('body', models.TextField()), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report', to=settings.AUTH_USER_MODEL)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report', to='exams.question')), + ], + ), + ] diff --git a/backend/exams/migrations/0023_alter_report_body.py b/backend/exams/migrations/0023_alter_report_body.py new file mode 100644 index 00000000..3f228074 --- /dev/null +++ b/backend/exams/migrations/0023_alter_report_body.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-07-31 14:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0022_auto_20220606_2355'), + ] + + operations = [ + migrations.AlterField( + model_name='report', + name='body', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/backend/exams/migrations/0024_alter_comment_date.py b/backend/exams/migrations/0024_alter_comment_date.py new file mode 100644 index 00000000..853d1550 --- /dev/null +++ b/backend/exams/migrations/0024_alter_comment_date.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-08-12 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0023_alter_report_body'), + ] + + operations = [ + migrations.AlterField( + model_name='comment', + name='date', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/backend/exams/models.py b/backend/exams/models.py index b2501732..6b757478 100644 --- a/backend/exams/models.py +++ b/backend/exams/models.py @@ -28,40 +28,70 @@ YEARS = [(0, "0"), (10, "10"), (11, "11"), (12, "12")] +RESOURCE_TYPES = ( + ("video", "Video"), + ("paper", "Paper"), +) + +ISSUE_TYPES = ( + ('Typo', 'Gralha no enunciado ou nas opções de resposta'), + ('SubmissionError', 'Erro na submissão'), + ('QuestionFormatting', 'Pergunta desformatada'), + ('LoadingError', 'Página não carrega'), + ('ImageError', 'Figura errada ou em falta'), + ('Other', 'Outro'), +) + + class Exam(models.Model): questions = models.ManyToManyField("Question", related_name="questions") - failed = models.ManyToManyField("Question", related_name="failed", blank=True) # Questions that were responded incorrectlty - correct = models.ManyToManyField("Question", related_name="correct", blank=True) # Questions that were responded correctlty - score = models.IntegerField(default=0) # 0 - 200 - subject = models.CharField(max_length=50, null=False, choices=SUBJECTS) # Math, Physics ... - year = models.IntegerField(default=0, null=False, choices=YEARS) # Geral: 0; 12º: 12... - difficulty = models.CharField(max_length=10, null=True, choices=DIFFICULTIES) - - #String representation - def __str__(self): return "{}-{}-{}".format(self.subject, self.year, self.difficulty) + # Questions that were responded incorrectlty + failed = models.ManyToManyField( + "Question", related_name="failed", blank=True) + # Questions that were responded correctlty + correct = models.ManyToManyField( + "Question", related_name="correct", blank=True) + score = models.IntegerField(default=0) # 0 - 200 + # Math, Physics ... + subject = models.CharField(max_length=50, null=False, choices=SUBJECTS) + # Geral: 0; 12º: 12... + year = models.IntegerField(default=0, null=False, choices=YEARS) + difficulty = models.CharField( + max_length=10, null=True, choices=DIFFICULTIES) + + # String representation + def __str__(self): return "{}-{}-{}".format(self.subject, + self.year, self.difficulty) def renameImage(instance, filename): - ext = filename.split(".")[-1] - if instance.pk: - return "question{}.{}".format(instance.pk, ext) + ext = filename.split(".")[-1] + if instance.pk: + return "question{}.{}".format(instance.pk, ext) class Question(models.Model): - author = models.ForeignKey(User, related_name="question", null=True, on_delete=CASCADE) + author = models.ForeignKey( + User, related_name="question", null=True, on_delete=CASCADE) accepted = models.BooleanField(null=True, default=False) text = models.CharField(max_length=1000, null=False) - subject = models.CharField(max_length=50, null=False, choices=SUBJECTS) # Math, Physics ... - subsubject = models.CharField(max_length=50, null=False, choices=SUB_SUBJECTS)# Geometry, Imaginary - year = models.IntegerField(default=0, null=False, choices=YEARS) # Geral: 0; 12º: 12... - difficulty = models.CharField(max_length=10, null=True, choices=DIFFICULTIES) + resolution = models.TextField(null=True) + # Math, Physics ... + subject = models.CharField(max_length=50, null=False, choices=SUBJECTS) + subsubject = models.CharField( + max_length=50, null=False, choices=SUB_SUBJECTS) # Geometry, Imaginary + # Geral: 0; 12º: 12... + year = models.IntegerField(default=0, null=False, choices=YEARS) + difficulty = models.CharField( + max_length=10, null=True, choices=DIFFICULTIES) image = models.ImageField(null=True, blank=True, upload_to=renameImage) source = models.CharField(max_length=500, null=True) date = models.DateField(auto_now_add=True) - #String representation - def __str__(self): return "{}-{}-{}".format(self.id,self.subsubject, self.year, self.difficulty) + # String representation + def __str__(self): return "{}-{}-{}".format(self.id, + self.subsubject, self.year, self.difficulty) def correctAnswer(self): return self.answer.get(question=self, correct=True) @@ -74,15 +104,19 @@ def getComments(self): class Comment(models.Model): - question = models.ForeignKey("question", related_name="comment", on_delete=models.CASCADE, null=False) + question = models.ForeignKey( + "question", related_name="comment", on_delete=models.CASCADE, null=False) content = models.CharField(max_length=250, null=False) # When user delets account, comment shouldn't be deleted, but signaled as "deleted user" or equivalent - author = models.ForeignKey(User, related_name="comment", on_delete=models.CASCADE, null=False) + author = models.ForeignKey( + User, related_name="comment", on_delete=models.CASCADE, null=False) #fatherComment = models.ForeignKey("comment", related_name="reply", on_delete=models.CASCADE, null=True) votes = models.IntegerField(default=0) - date = models.DateField(auto_now_add=True) - upvoters = models.ManyToManyField(User, related_name="upvoters", blank=True) - downvoters = models.ManyToManyField(User, related_name="downvoters" ,blank=True) + date = models.DateTimeField(auto_now_add=True) + upvoters = models.ManyToManyField( + User, related_name="upvoters", blank=True) + downvoters = models.ManyToManyField( + User, related_name="downvoters", blank=True) def upvote(self, user): """ Upvotes a comment. Returns 1 if done successfully, 0 if not """ @@ -98,7 +132,6 @@ def upvote(self, user): return 1 - def downvote(self, user): """ Downvotes a comment. Returns 1 if done successfully, 0 if not """ @@ -112,10 +145,32 @@ def downvote(self, user): self.save() return 1 - class Answer(models.Model): - text = models.TextField(max_length=100,null=False) + text = models.TextField(max_length=100, null=False) correct = models.BooleanField(default=False) - question = models.ForeignKey("question", related_name="answer", on_delete=models.CASCADE, null=True) + question = models.ForeignKey( + "question", related_name="answer", on_delete=models.CASCADE, null=True) + + +class Report(models.Model): + question = models.ForeignKey( + "question", related_name="report", on_delete=models.CASCADE, null=False) + author = models.ForeignKey( + User, related_name="report", on_delete=models.CASCADE, null=False) + date = models.DateTimeField(auto_now_add=True) + + type = models.CharField(max_length=50, choices=ISSUE_TYPES) + body = models.TextField(blank=True, null=True) + + def __str__(self): + return str(self.date.date()) + ' || ' + self.type + ' || ' + str(self.question) + + +class Resource(models.Model): + type = models.CharField(null=False, choices=RESOURCE_TYPES, max_length=50) + url = models.TextField(null=False) + description = models.TextField(null=False) + question = models.ForeignKey( + Question, on_delete=CASCADE, related_name="resources") diff --git a/backend/exams/urls.py b/backend/exams/urls.py deleted file mode 100644 index 2a7f718c..00000000 --- a/backend/exams/urls.py +++ /dev/null @@ -1,19 +0,0 @@ -# users/urls.py - -from django.conf.urls import url, include -from django.urls import path -from .views import * - - -app_name = 'exams' -urlpatterns = [ - url("list", list_exams), - path("results/", results), - path('/render',exam_id_render), - path("gerador/", generate_exam), - path("question/", questionPage), - path("delete_comment/", deleteComment), - path("add_comment", addComment), - path("upvote/", upvoteComment), - path("downvote/", downvoteComment) -] \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 43eb8b3d..f8af8365 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,7 +13,7 @@ click-repl==0.2.0 cryptography==3.4.7 defusedxml==0.7.1 Deprecated==1.2.13 -Django==3.2.13 +Django==3.2.15 django-allauth==0.45.0 django-celery-beat==2.2.1 django-cors-headers==3.7.0 @@ -23,9 +23,9 @@ djangorestframework==3.12.4 gunicorn==20.1.0 idna==3.2 kombu==5.2.4 -oauthlib==3.1.1 +oauthlib==3.2.1 packaging==21.3 -Pillow==9.1.0 +Pillow==9.1.1 prompt-toolkit==3.0.29 psycopg2==2.9.1 pycparser==2.20 diff --git a/backend/users/urls.py b/backend/users/urls.py deleted file mode 100644 index dc120673..00000000 --- a/backend/users/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.conf.urls import url, include -from django.contrib.auth import views as auth_views -from .views import * - - -urlpatterns = [ - url(r'^login/$', auth_views.LoginView.as_view(), name='login'), - url(r'^logout/$', auth_views.LogoutView.as_view() ,name='logout'), - url(r'^signup/', signup, name="signup"), - url(r'^perfil/', profileDashboard, name="perfil") -] diff --git a/docker-compose.override.yml b/docker-compose.override.yml index a0dce17b..e0d3ffc6 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -5,10 +5,10 @@ services: service: backend container_name: backend-dev command: python ./manage.py runserver 0.0.0.0:8000 + env_file: + - backend/.env volumes: - ./backend/:/code/ - environment: - - DJANGO_DEBUG=True ports: - 8000:8000 diff --git a/docker-compose.yml b/docker-compose.yml index 0e076fcf..d2b9ea2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,8 @@ services: frontend: build: ./frontend restart: always + volumes: + - '/app/node_modules' depends_on: - backend links: diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 0e56f2cb..3e1a2549 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -20,6 +20,7 @@ "env": { "node": true }, + "parser": "babel-eslint", "parserOptions": { "ecmaVersion": 2020, "sourceType": "module", diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 44031585..2d63ec5d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -4,9 +4,6 @@ FROM node:16-alpine AS development # set the working direction WORKDIR /app -# add `/app/node_modules/.bin` to $PATH -ENV PATH /app/node_modules/.bin:$PATH - # install app dependencies COPY package.json ./ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cf62e7d6..554a6df2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.1.0", + "version": "1.0.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.1.0", + "version": "1.0.3", "dependencies": { "@emotion/react": "^11.8.2", "@emotion/styled": "^11.8.1", @@ -36,6 +36,7 @@ "web-vitals": "^1.1.2" }, "devDependencies": { + "babel-eslint": "^10.1.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.0.0", "prettier": "2.6.2" @@ -2919,6 +2920,28 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.13", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz", @@ -5181,6 +5204,36 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==" }, + "node_modules/babel-eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", + "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", + "deprecated": "babel-eslint is now @babel/eslint-parser. This package will no longer receive updates.", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.0", + "@babel/traverse": "^7.7.0", + "@babel/types": "^7.7.0", + "eslint-visitor-keys": "^1.0.0", + "resolve": "^1.12.0" + }, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "eslint": ">= 4.12.1" + } + }, + "node_modules/babel-eslint/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/babel-jest": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", @@ -17484,13 +17537,13 @@ } }, "node_modules/terser": { - "version": "5.13.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.13.1.tgz", - "integrity": "sha512-hn4WKOfwnwbYfe48NgrQjqNOH9jzLqRcIfbYytOXCOv46LBfWr9bDS17MQqOi+BWGD0sJK3Sj5NC/gJjiojaoA==", + "version": "5.14.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz", + "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==", "dependencies": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.8.0-beta.0", "source-map-support": "~0.5.20" }, "bin": { @@ -17557,40 +17610,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, - "node_modules/terser/node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "dependencies": { - "whatwg-url": "^7.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/terser/node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/terser/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" - }, - "node_modules/terser/node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -21551,6 +21570,27 @@ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.1.tgz", "integrity": "sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ==" }, + "@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "dependencies": { + "@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + } + } + }, "@jridgewell/sourcemap-codec": { "version": "1.4.13", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz", @@ -23155,6 +23195,28 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==" }, + "babel-eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", + "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.0", + "@babel/traverse": "^7.7.0", + "@babel/types": "^7.7.0", + "eslint-visitor-keys": "^1.0.0", + "resolve": "^1.12.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, "babel-jest": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", @@ -31903,13 +31965,13 @@ } }, "terser": { - "version": "5.13.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.13.1.tgz", - "integrity": "sha512-hn4WKOfwnwbYfe48NgrQjqNOH9jzLqRcIfbYytOXCOv46LBfWr9bDS17MQqOi+BWGD0sJK3Sj5NC/gJjiojaoA==", + "version": "5.14.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz", + "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==", "requires": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.8.0-beta.0", "source-map-support": "~0.5.20" }, "dependencies": { @@ -31922,37 +31984,6 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" - }, - "source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "requires": { - "whatwg-url": "^7.0.0" - } - }, - "tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", - "requires": { - "punycode": "^2.1.0" - } - }, - "webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" - }, - "whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "requires": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } } } }, diff --git a/frontend/package.json b/frontend/package.json index a9b74f1c..15e501ed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.0.3", + "version": "1.1.0", "private": true, "scripts": { "start": "react-scripts start", @@ -58,6 +58,7 @@ ] }, "devDependencies": { + "babel-eslint": "^10.1.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.0.0", "prettier": "2.6.2" diff --git a/frontend/src/App.js b/frontend/src/App.js index 00c9c82b..25145da1 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,5 +1,6 @@ +/* eslint-disable no-unused-vars */ import React from 'react'; -// import QuestionPage from './pages/QuestionPage.js'; +import QuestionPage from './pages/QuestionPage.js'; import LoginPage from './pages/LoginPage.js'; import GenExamPage from './pages/GenExamPage.js'; import ExamPage from './pages/ExamPage.js'; @@ -14,6 +15,7 @@ import PasswordResetPage from './pages/PasswordResetPage.js'; import PasswordResetConfirmPage from './pages/PasswordResetConfirmPage.js'; import LeaderboardPage from './pages/LeaderboardPage.js'; import PageNotFound from './pages/PageNotFound.js'; +import ReportsPage from './pages/ReportsPage.js'; import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; import axios from 'axios'; import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles'; @@ -45,51 +47,38 @@ function App() {
-
- - - {/* - - - */} - - - - - - - - - - - - - -
+ + + + + + + + + + + + + + + + +
diff --git a/frontend/src/api.js b/frontend/src/api.js index 73b437dc..74a4b725 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -72,34 +72,26 @@ export async function questionInfo(id, successCall) { .catch((error) => console.log(error)); } -export async function hasDownvotedAPI(id, successCall) { - axios.get('api/has_downvoted/' + id).then((res) => successCall(res)); -} - -export async function hasUpvotedAPI(id, successCall) { - axios.get('api/has_upvoted/' + id).then((res) => successCall(res)); -} - export async function upvoteAPI(id, successCall) { axios.post('api/upvote/' + id).then((res) => successCall(res)); } -export async function downvoteAPI(id, successCall) { - axios.post('api/downvote/' + id).then((res) => successCall(res)); -} - -export async function deleteCommentAPI(id, successCall) { - axios.delete('api/comment/' + id).then((res) => successCall(res)); -} - export async function removeUpvoteAPI(id, successCall) { axios.delete('api/upvote/' + id).then((res) => successCall(res)); } +export async function downvoteAPI(id, successCall) { + axios.post('api/downvote/' + id).then((res) => successCall(res)); +} + export async function removeDownvoteAPI(id, successCall) { axios.delete('api/downvote/' + id).then((res) => successCall(res)); } +export async function deleteCommentAPI(id, successCall) { + axios.delete('api/comment/' + id).then((res) => successCall(res)); +} + export async function createCommentAPI(body, successCall) { axios.post('api/comment/', body).then((res) => successCall(res)); } @@ -249,3 +241,23 @@ export async function deleteAccount(password, successCall, errorCall) { .then((res) => successCall(res)) .catch((error) => errorCall(error)); } + +export const fetchQuestion = async (id, successCall, errorCall) => { + axios + .get('api/question/' + id) + .then((res) => successCall(res)) + .then((error) => errorCall(error)); +}; + +export async function createReport(body, successCall, errorCall) { + axios + .post('api/report/', body) + .then((res) => successCall(res)) + .catch((error) => errorCall(error)); +} +export async function getReports(successCall) { + axios.get('api/reports/').then((res) => successCall(res)); +} +export async function deleteReport(id, successCall) { + axios.delete('api/report/' + id).then((res) => successCall(res)); +} diff --git a/frontend/src/assets/deleteComment.svg b/frontend/src/assets/deleteComment.svg new file mode 100644 index 00000000..c7d12700 --- /dev/null +++ b/frontend/src/assets/deleteComment.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/desformatIcon.svg b/frontend/src/assets/desformatIcon.svg new file mode 100644 index 00000000..6a305d7e --- /dev/null +++ b/frontend/src/assets/desformatIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/detective.svg b/frontend/src/assets/detective.svg new file mode 100644 index 00000000..2205ed03 --- /dev/null +++ b/frontend/src/assets/detective.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/downvote.svg b/frontend/src/assets/downvote.svg new file mode 100644 index 00000000..ffa89223 --- /dev/null +++ b/frontend/src/assets/downvote.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/downvoteFilled.svg b/frontend/src/assets/downvoteFilled.svg new file mode 100644 index 00000000..6a340f2b --- /dev/null +++ b/frontend/src/assets/downvoteFilled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/imageIcon.svg b/frontend/src/assets/imageIcon.svg new file mode 100644 index 00000000..e391f417 --- /dev/null +++ b/frontend/src/assets/imageIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/loadingIcon.svg b/frontend/src/assets/loadingIcon.svg new file mode 100644 index 00000000..98130b5b --- /dev/null +++ b/frontend/src/assets/loadingIcon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/src/assets/messageReport.svg b/frontend/src/assets/messageReport.svg new file mode 100644 index 00000000..7b7619a9 --- /dev/null +++ b/frontend/src/assets/messageReport.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/onlyBrainLogo.svg b/frontend/src/assets/onlyBrainLogo.svg new file mode 100644 index 00000000..a7b2f53b --- /dev/null +++ b/frontend/src/assets/onlyBrainLogo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/assets/otherIcon.svg b/frontend/src/assets/otherIcon.svg new file mode 100644 index 00000000..5b555343 --- /dev/null +++ b/frontend/src/assets/otherIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/submissionIcon.svg b/frontend/src/assets/submissionIcon.svg new file mode 100644 index 00000000..c2e740e8 --- /dev/null +++ b/frontend/src/assets/submissionIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/textBubbleIcon.svg b/frontend/src/assets/textBubbleIcon.svg new file mode 100644 index 00000000..8d50f313 --- /dev/null +++ b/frontend/src/assets/textBubbleIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/upvote.svg b/frontend/src/assets/upvote.svg new file mode 100644 index 00000000..ad1d8510 --- /dev/null +++ b/frontend/src/assets/upvote.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/upvoteFilled.svg b/frontend/src/assets/upvoteFilled.svg new file mode 100644 index 00000000..bcdf0503 --- /dev/null +++ b/frontend/src/assets/upvoteFilled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/Box/Box.js b/frontend/src/components/Box/Box.js new file mode 100644 index 00000000..c22f88fa --- /dev/null +++ b/frontend/src/components/Box/Box.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { Paper } from '@mui/material'; +import theme from '../../globalTheme'; + +const sxStyles = { + box: { + border: '2px solid', + borderRadius: 5, + borderColor: theme.palette.grey.primary, + boxShadow: '-6px 7px 16px rgba(0, 0, 0, 0.25)', + padding: '1rem', + }, +}; + +const Box = (props) => { + return ( + + {props.children} + + ); +}; + +export default Box; diff --git a/frontend/src/components/Math/MathBlock.js b/frontend/src/components/Math/MathBlock.js new file mode 100644 index 00000000..f7db9dcd --- /dev/null +++ b/frontend/src/components/Math/MathBlock.js @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkMath from 'remark-math'; +import remarkKatex from 'rehype-katex'; +import remarRehype from 'remark-rehype'; + +export const MathBlock = (props) => { + return ( + + {...props} + + ); +}; diff --git a/frontend/src/components/MenuCircular/MenuCircular.js b/frontend/src/components/MenuCircular/MenuCircular.js index af270805..092c99e9 100644 --- a/frontend/src/components/MenuCircular/MenuCircular.js +++ b/frontend/src/components/MenuCircular/MenuCircular.js @@ -44,7 +44,10 @@ const MenuCircular = (props) => { return ( <> - + diff --git a/frontend/src/components/box/Box.js b/frontend/src/components/box/Box.js new file mode 100644 index 00000000..c22f88fa --- /dev/null +++ b/frontend/src/components/box/Box.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { Paper } from '@mui/material'; +import theme from '../../globalTheme'; + +const sxStyles = { + box: { + border: '2px solid', + borderRadius: 5, + borderColor: theme.palette.grey.primary, + boxShadow: '-6px 7px 16px rgba(0, 0, 0, 0.25)', + padding: '1rem', + }, +}; + +const Box = (props) => { + return ( + + {props.children} + + ); +}; + +export default Box; diff --git a/frontend/src/components/buttons/IconButton.js b/frontend/src/components/buttons/IconButton.js index df7d2128..1de379cd 100644 --- a/frontend/src/components/buttons/IconButton.js +++ b/frontend/src/components/buttons/IconButton.js @@ -8,7 +8,6 @@ import globalTheme from '../../globalTheme'; const useStyles = makeStyles(() => ({ button: (props) => ({ borderRadius: 25, - fontSize: props.fontSize, textTransform: 'none', borderRigth: '4px', padding: '1rem', @@ -44,15 +43,23 @@ const IconButton = (props) => { target={props.target} classes={{ root: classes.button, label: classes.label }} onClick={props.onClick} + {...props} > {props.iconFirst ? ( <> <>{props.children} - {props.text} + + {' '} + {props.text}{' '} + ) : ( <> - + {' '} {props.text}{' '} @@ -74,8 +81,6 @@ IconButton.propTypes = { direction: PropTypes.string, iconFirst: PropTypes.bool, spacing: PropTypes.number, - width: PropTypes.string, - height: PropTypes.string, variant: PropTypes.string, justifyContent: PropTypes.string, alignItems: PropTypes.string, @@ -86,13 +91,10 @@ IconButton.propTypes = { IconButton.defaultProps = { color: 'white', backgroundColor: globalTheme.palette.secondary.main, - fontSize: 100, scale: 1.05, direction: 'row', iconFirst: true, spacing: 6, - height: '23vh', - width: '12vw', variant: 'h5', justifyContent: 'space-between', alignItems: 'start', diff --git a/frontend/src/components/chat/Chat.js b/frontend/src/components/chat/Chat.js new file mode 100644 index 00000000..0cf3121f --- /dev/null +++ b/frontend/src/components/chat/Chat.js @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import Comment from './Comment'; +import { Grid, TextField, IconButton } from '@mui/material'; +import Box from '../Box/Box'; +import SendRoundedIcon from '@mui/icons-material/SendRounded'; +import { createCommentAPI } from '../../api'; +import isSwear from '../../utils/isSwear'; +import { useSnackbar } from 'notistack'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import responsiveWidth from '../../hooks/responsiveWidth'; + +export function Chat(props) { + const { enqueueSnackbar } = useSnackbar(); + const [commentText, setCommentText] = useState(''); + const windowArray = useWindowDimensions(); + + const sxStyles = { + textfieldPlaceholder: { + '& .MuiInputBase-input': { + fontSize: responsiveWidth(windowArray, 13, 20, 0.01), + }, + }, + responsiveIcons: { width: responsiveWidth(windowArray, 20, 40, 0.025), height: 'auto' }, + }; + + const handleVoteChange = (id, vote) => { + const newComments = props.messageArray.map((comment) => { + if (comment.id === id) { + comment.votes = vote; + } + return comment; + }); + props.sortRerender(newComments); + }; + const handleComment = (e) => { + setCommentText(e.target.value); + }; + + const handleCommentSubmition = () => { + const body = { + content: commentText, + question: props.questionID, + }; + if (isSwear(body.content)) { + enqueueSnackbar('O seu comentário contém expressões agressivas/impróprias', { + variant: 'warning', + }); + } else { + createCommentAPI(body, (res) => { + const newComments = props.messageArray.concat(res.data); + props.sortRerender(newComments); + }); + } + setCommentText(''); + }; + + const deleteComment = (id) => { + const newComments = props.messageArray.filter((e) => e.id !== id); + props.sortRerender(newComments); + }; + + return ( + + {props.messageArray.map((comment) => ( + + ))} + + + {' '} + + {' '} + + + + + {' '} + + + + + + + ); +} diff --git a/frontend/src/components/chat/Comment.js b/frontend/src/components/chat/Comment.js new file mode 100644 index 00000000..85e39011 --- /dev/null +++ b/frontend/src/components/chat/Comment.js @@ -0,0 +1,198 @@ +import React, { useState, useEffect } from 'react'; +import { + deleteCommentAPI, + upvoteAPI, + downvoteAPI, + removeUpvoteAPI, + removeDownvoteAPI, +} from '../../api'; +import { ReactComponent as DeleteIcon } from '../../assets/deleteComment.svg'; +import { ReactComponent as UpvoteIcon } from '../../assets/upvote.svg'; +import { ReactComponent as DownvoteIcon } from '../../assets/downvote.svg'; +import { ReactComponent as UpvoteFilledIcon } from '../../assets/upvoteFilled.svg'; +import { ReactComponent as DownvoteFilledIcon } from '../../assets/downvoteFilled.svg'; +import { Grid, IconButton } from '@mui/material'; +import AvatarUser from '../avatar/AvatarUser'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import responsiveWidth from '../../hooks/responsiveWidth'; +import Box from '../Box/Box'; +import Description from '../typographies/Description'; + +export default function Comment(props) { + const [voted, setVoted] = useState(props.comment.voted); + const [votes, setVotes] = useState(props.comment.votes); + const [timeShown, setTimeShown] = useState(null); + + const windowArray = useWindowDimensions(); + + const sxStyles = { + arrowIcons: { height: responsiveWidth(windowArray, 22, 50, 0.012) }, + deleteIcons: { height: responsiveWidth(windowArray, 18, 50, 0.015) }, + author: { + color: '#BEBEBE', + fontWeight: 700, + fontSize: 18, + marginLeft: '0.5rem', + }, + voteCounter: { + fontWeight: 600, + fontSize: 18, + }, + avatarComment: { + width: responsiveWidth(windowArray, undefined, 75, 0.035), + height: responsiveWidth(windowArray, undefined, 75, 0.035), + }, + descriptiveText: { + fontSize: responsiveWidth(windowArray, 10, 30, 0.012), + }, + }; + + const handleCommentDelete = () => { + deleteCommentAPI(props.comment.id, () => { + props.deleteComment(props.comment.id); + }); + }; + + const handleCommentUpvote = () => { + let votesToAdd = 1; + if (voted === 1) { + removeUpvoteAPI(props.comment.id, () => { + setVoted(0); + setVotes(votes - 1); + props.handleVoteChange(props.comment.id, votes - 1); + }); + + return; + } else if (voted === -1) votesToAdd++; + + upvoteAPI(props.comment.id, () => { + setVoted(1); + setVotes(votes + votesToAdd); + props.handleVoteChange(props.comment.id, votes + votesToAdd); + }); + }; + + const handleCommentDownvote = () => { + let votesToRemove = 1; + if (voted === -1) { + removeDownvoteAPI(props.comment.id, () => { + setVoted(0); + setVotes(votes + 1); + props.handleVoteChange(props.comment.id, votes + 1); + }); + + return; + } else if (voted === 1) votesToRemove++; + + downvoteAPI(props.comment.id, () => { + setVoted(-1); + setVotes(votes - votesToRemove); + props.handleVoteChange(props.comment.id, votes - votesToRemove); + }); + }; + function getCurrentDate(separator = '-') { + let newDate = new Date(); + let date = newDate.getDate(); + let month = newDate.getMonth() + 1; + let year = newDate.getFullYear(); + + return `${year}${separator}${month < 10 ? `0${month}` : `${month}`}${separator}${date}`; + } + + useEffect(() => { + const currentDate = getCurrentDate('-'); + const time = props.comment.date.slice(11, -11); //Gets the time HH:MM + const date = props.comment.date.slice(0, 10); //Date YYYY-MM-DD + if (currentDate === date) { + setTimeShown('- ' + time); + } else { + setTimeShown('- ' + date + ' ' + time); + } + }, []); + + return ( + + {/* Comment Area */} + + {' '} + {/* User Photo*/} + + + + + {' '} + {/* Username*/} + + {props.comment.author.username} {timeShown} + + + + + {' '} + {/* Comment content*/}{' '} + + {props.comment.content} + + + + {/* Upvotes Area */} + + handleCommentUpvote()}> + {voted === 1 ? ( + + ) : ( + + )} + + + + + {votes} + + + + {' '} + handleCommentDownvote()}> + {voted === -1 ? ( + + ) : ( + + )} + + + + + {/* Delete Area */} + + {props.userID === props.comment.author.id || props.isUserMod ? ( + handleCommentDelete()}> + + + ) : ( + <> + )} + + + + + + ); +} diff --git a/frontend/src/components/countdownClock/CountdownClock.js b/frontend/src/components/countdownClock/CountdownClock.js index c49e3303..9cce7547 100644 --- a/frontend/src/components/countdownClock/CountdownClock.js +++ b/frontend/src/components/countdownClock/CountdownClock.js @@ -2,6 +2,8 @@ import React, { useRef } from 'react'; import { Paper, Typography } from '@mui/material'; import Countdown from 'react-countdown'; import makeStyles from '@mui/styles/makeStyles'; +import responsiveWidth from '../../hooks/responsiveWidth'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; const useStyles = makeStyles((theme) => ({ clock: { @@ -35,9 +37,15 @@ const CountdownClock = (props) => { }, 2000); }; + const windowArray = useWindowDimensions(); + return ( - + diff --git a/frontend/src/components/dialogs/ReportDialog.js b/frontend/src/components/dialogs/ReportDialog.js new file mode 100644 index 00000000..6ee84b2a --- /dev/null +++ b/frontend/src/components/dialogs/ReportDialog.js @@ -0,0 +1,272 @@ +import * as React from 'react'; +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + DialogTitle, + Dialog, + IconButton, + DialogContent, + Grid, + Typography, + Button, + TextField, +} from '@mui/material'; +import theme from '../../globalTheme'; +import CloseIcon from '@mui/icons-material/Close'; +import { grey } from '@mui/material/colors'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import makeStyles from '@mui/styles/makeStyles'; +import { ReactComponent as TextBubbleIcon } from '../../assets/textBubbleIcon.svg'; +import { ReactComponent as SubmissionIcon } from '../../assets/submissionIcon.svg'; +import { ReactComponent as DesformatIcon } from '../../assets/desformatIcon.svg'; +import { ReactComponent as LoadingIcon } from '../../assets/loadingIcon.svg'; +import { ReactComponent as ImageIcon } from '../../assets/imageIcon.svg'; +import { ReactComponent as OtherIcon } from '../../assets/otherIcon.svg'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import responsiveWidth from '../../hooks/responsiveWidth'; +import NormalButton from '../buttons/NormalButton'; +import Box from '../Box/Box'; + +const reportData = [ + { + icon: , + text: 'Enunciado/Opções incorrectas', + type: 'Typo', + }, + { icon: , text: 'Erro na submissão', type: 'SubmissionError' }, + { icon: , text: 'Pergunta desformatada', type: 'QuestionFormatting' }, + { icon: , text: 'A página não carrega', type: 'LoadingError' }, + { icon: , text: 'Figura errada ou em falta', type: 'ImageError' }, + { icon: , text: 'Outro...', type: 'Other' }, +]; + +const useStyles = makeStyles(() => ({ + reportText: { + wordWrap: 'break-word', + fontWeight: 'bold', + flexWrap: 'nowrap', + textTransform: 'none', + }, + centerGrid: { + padding: '2.5rem 0rem 0rem 3rem', + }, + reportButton: {}, + paddingGrid: { padding: '3rem 0rem 0rem 0rem' }, +})); + +export default function ReportDialog(props) { + const classes = useStyles(); + const [reportType, setReportType] = useState(null); + const [otherDescription, setOtherDescription] = useState(null); + + const handleClose = () => { + //In the end we Reset the reportType State and Discard The Information + setReportType(null); + setOtherDescription(null); + props.onClose(null, null); // Since the state is only updated afterwards + }; + + const handleSubmission = () => { + //In the end we Reset the reportType State and Submit the Information + props.onClose(reportType, otherDescription); + setReportType(null); + setOtherDescription(null); + }; + const handleOtherDescription = (e) => { + setOtherDescription(e.target.value); + }; + + const windowArray = useWindowDimensions(); + const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + + return ( + + + + Reportar + + {props.onClose ? ( + + + + ) : null} + + + + + {reportData.map((report) => ( + + + + ))} + + {reportType === 'Other' ? ( // Caso a modalidade seja Outros, desbloqueia os passos seguintes + <> + + + {' '} + + {' '} + + + + + + + + + ) : reportType !== null ? ( //O utilizador escolhe uma modalidade que não Outro, desbloqueia os passos seguintes + <> + + + {' '} + + {' '} + + + + + + + + + ) : null} + + + + ); +} + +ReportDialog.propTypes = { + onClose: PropTypes.func.isRequired, + open: PropTypes.bool.isRequired, +}; diff --git a/frontend/src/components/inputs/TextInput.js b/frontend/src/components/inputs/TextInput.js index 52d3c41a..63ee31e8 100644 --- a/frontend/src/components/inputs/TextInput.js +++ b/frontend/src/components/inputs/TextInput.js @@ -23,6 +23,7 @@ export const TextInput = (props) => { margin: '0 1rem 0 1rem', fontSize: props.fontSize, color: props.fontColor && props.fontColor, + border: 'none', }, }} {...props} diff --git a/frontend/src/components/login/LoginInput.js b/frontend/src/components/login/LoginInput.js index 49657597..5970c28c 100644 --- a/frontend/src/components/login/LoginInput.js +++ b/frontend/src/components/login/LoginInput.js @@ -5,25 +5,19 @@ import React, { useState } from 'react'; import NormalButton from '../buttons/NormalButton'; import { ReactComponent as Logo } from '../../assets/logo_white.svg'; import { useSnackbar } from 'notistack'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import responsiveWidth from '../../hooks/responsiveWidth'; +import responsiveHeight from '../../hooks/responsiveHeight'; const useStyles = makeStyles((theme) => ({ input: { backgroundColor: '#fff', borderRadius: 50, disableUnderline: true, - height: 65, - width: '22rem', - }, - container: { - width: '100%', - }, - containerForm: { - width: '100%', - marginTop: '7rem', }, + resetPasswordText: { color: theme.palette.background.default, - fontSize: 18, '&:hover': { color: theme.palette.secondary.main, }, @@ -40,6 +34,7 @@ const LoginInput = () => { const handleChangePassword = (e) => setPassword(e.target.value); const { enqueueSnackbar } = useSnackbar(); + const windowArray = useWindowDimensions(); const handleClick = () => { logIn(username, password, (error) => { @@ -62,28 +57,43 @@ const LoginInput = () => { return ( - + { { /> - + Não sabes a tua Password ? - - + + - + Ainda não tens conta? Regista-te diff --git a/frontend/src/components/navbar/Navbar.js b/frontend/src/components/navbar/Navbar.js index a2f66a54..b025a34e 100644 --- a/frontend/src/components/navbar/Navbar.js +++ b/frontend/src/components/navbar/Navbar.js @@ -9,8 +9,8 @@ import { Link, Box, Typography, + Grid, } from '@mui/material'; -import makeStyles from '@mui/styles/makeStyles'; import MenuIcon from '@mui/icons-material/Menu'; import NavbarButton from './NavbarButton'; import { ReactComponent as Logo } from '../../assets/logo_blue.svg'; @@ -21,103 +21,194 @@ import Loading from '../loading/Loading'; import MenuCircular from '../MenuCircular/MenuCircular'; import { Link as LinkRouter } from 'react-router-dom'; import theme from '../../globalTheme'; - -const useStyles = makeStyles((theme) => ({ - menuButton: { - marginRight: theme.spacing(2), - color: 'grey', - }, - title: { - flexGrow: 1, - color: 'grey', - }, - navbar: { - backgroundColor: theme.palette.background.default, - boxShadow: theme.shadows[0], - marginTop: '1.5rem', - height: 90, - marginBottom: '3rem', - }, - menu: { - [theme.breakpoints.down('md')]: { - display: 'none', - }, - }, - menuMobile: { - color: 'grey', - [theme.breakpoints.up('md')]: { - display: 'none', - }, - }, - toolbar: { - height: 300, - marginRight: '20rem', - marginLeft: '20rem', - }, - logo: { - height: '5rem', - width: 'auto', - margin: 0, - }, - registerBtn: { - borderRadius: '15', - color: 'red', - }, - circular: { - paddingBottom: '1rem', - }, - avatar: { - width: 80, - height: 80, - }, - link: { - textDecoration: 'none', - color: 'black', - fontSize: '22px', - marginLeft: theme.spacing(2), - transition: 'all 0.15s ease-in-out', - '&:hover': { color: theme.palette.secondary.main }, - paddingRight: '2rem', - fontWeight: 'bold', - }, -})); +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import responsiveHeight from '../../hooks/responsiveHeight'; +import responsiveWidth from '../../hooks/responsiveWidth'; +import { logOut } from '../../api'; +import { ReactComponent as OnlyBrainLogo } from '../../assets/onlyBrainLogo.svg'; +import { ReactComponent as Detective } from '../../assets/detective.svg'; const Navbar = () => { - const classes = useStyles(theme); const [click, setClick] = useState(false); const handleClick = () => { setClick(!click); - console.log(click); }; const [user, loading] = useContext(userContext); + const windowArray = useWindowDimensions(); + + const sxStyles = { + navbar: { + backgroundColor: theme.palette.background.default, + boxShadow: theme.shadows[0], + marginTop: '1.5rem', + height: 90, + marginBottom: responsiveHeight(windowArray, undefined, 10, 0.01), + }, + menuMobile: { + color: 'grey', + }, + toolbar: { + height: 300, + justifyContent: windowArray.width >= 900 ? 'space-around' : 'space-between', + }, + logo: { + height: responsiveHeight(windowArray, undefined, 100, 0.08), + width: 'auto', + }, + avatar: { + height: responsiveHeight(windowArray, 50, 100, 0.1), + width: 'auto', + }, + link: { + textDecoration: 'none', + color: 'black', + fontSize: responsiveWidth(windowArray, 15, 35, 0.0125), + marginLeft: theme.spacing(2), + transition: 'all 0.15s ease-in-out', + '&:hover': { color: theme.palette.secondary.main }, + paddingRight: '2rem', + fontWeight: 'bold', + }, + svg: { + height: responsiveHeight(windowArray, 200, undefined, 0.25), + }, + }; + + const handleLogout = () => { + logOut(); + window.location.replace('/'); + }; return ( - - - - - + + + + + {loading ? ( - ) : user ? ( + ) : user && windowArray.width >= 900 ? (
- + Gerar Exame Leaderboards - + +
+ ) : windowArray.width < 900 ? ( +
+ + + + + {user ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + {' '} + + + + + + ) : ( + + + + + + + + + + + + + + + + + + + + + + {' '} + + + + + + )} +
) : (
@@ -131,44 +222,6 @@ const Navbar = () => {
)} - -
- - - - - - - {user ? ( - - ) : ( - || ( - - ) - )} - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/frontend/src/components/navbar/NavbarButton.js b/frontend/src/components/navbar/NavbarButton.js index 1c14ca31..e108c9c0 100644 --- a/frontend/src/components/navbar/NavbarButton.js +++ b/frontend/src/components/navbar/NavbarButton.js @@ -1,20 +1,22 @@ import React from 'react'; import { Button } from '@mui/material'; -import makeStyles from '@mui/styles/makeStyles'; - -const useStyles = makeStyles(() => ({ - menuItems: { - fontSize: 18, - }, -})); +import Subtitle from '../typographies/Subtitle'; +import globalTheme from '../../globalTheme'; const NavbarButton = (props) => { - const classes = useStyles(); - const color = window.location.pathname === props.href ? 'orange' : 'grey'; - + // eslint-disable-next-line no-unused-vars + const currentPage = window.location.pathname === props.href; return ( - ); }; diff --git a/frontend/src/components/questions/Answers.js b/frontend/src/components/questions/Answers.js index 9f8db520..c798c542 100644 --- a/frontend/src/components/questions/Answers.js +++ b/frontend/src/components/questions/Answers.js @@ -6,6 +6,8 @@ import ReactMarkdown from 'react-markdown'; import remarkMath from 'remark-math'; import remarkKatex from 'rehype-katex'; import remarRehype from 'remark-rehype'; +import responsiveWidth from '../../hooks/responsiveWidth'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; const useStyles = makeStyles(() => ({ paperAnswer: () => ({ @@ -27,7 +29,6 @@ const useStyles = makeStyles(() => ({ right: -5, }, answerText: { - fontSize: 18, padding: 8, wordWrap: 'break-word', }, @@ -40,6 +41,7 @@ const Answer = (props) => { // do something to change the answer props.changeAnswer(props.answer.id); }; + const windowArray = useWindowDimensions(); return ( { }, }} > - + {props.answer.text} diff --git a/frontend/src/components/questions/Question.js b/frontend/src/components/questions/Question.js index a4db2b04..e99fd34c 100644 --- a/frontend/src/components/questions/Question.js +++ b/frontend/src/components/questions/Question.js @@ -1,4 +1,3 @@ -/* eslint-disable no-unused-vars */ import React, { useState, useEffect, useRef } from 'react'; import { Typography, Grid, Paper } from '@mui/material'; import makeStyles from '@mui/styles/makeStyles'; @@ -8,15 +7,16 @@ import ReactMarkdown from 'react-markdown'; import remarkMath from 'remark-math'; import remarkKatex from 'rehype-katex'; import remarRehype from 'remark-rehype'; +import responsiveWidth from '../../hooks/responsiveWidth'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; -const useStyles = makeStyles((boxWidth) => ({ +const useStyles = makeStyles(() => ({ questionBox: { borderRadius: 20, boxShadow: '0px 8px 8px #9A9A9A', backgroundColor: 'white', border: '0.05rem solid #D9D9D9', minWidth: '25vw', - maxWidth: '60vw', }, answers: { @@ -41,9 +41,7 @@ const useStyles = makeStyles((boxWidth) => ({ padding: 5, borderColor: '#EB5757', position: 'relative', - height: '2rem', top: '-1rem', - width: '9rem', }, bold: { @@ -72,7 +70,6 @@ const Question = (props) => { const answerBox = useRef(); const [boxWidth, setBoxWidth] = useState(); - const [boxHeight, setBoxHeight] = useState(); const handleAnswer = (newAnswer) => { setSelectedAnswer(newAnswer); @@ -85,6 +82,7 @@ const Question = (props) => { const difference = widthAll - widthAnswers - 100; setBoxWidth(difference); }; + const windowArray = useWindowDimensions(); useEffect(() => { computeQuestionTextSize(); }, []); @@ -100,6 +98,7 @@ const Question = (props) => { direction='row' justifyContent='space-between' ref={questionBox} + style={{ maxWidth: !props.overrideResponsive && windowArray.width * 0.55 }} > { > {/* Question's number */} - - + + {' '} {props.preview ? 'Preview' : 'Questão ' + (props.answer + 1)}{' '} @@ -119,7 +127,12 @@ const Question = (props) => { {/* Question's text */} - + {props.question.text} @@ -155,4 +168,8 @@ const Question = (props) => { ); }; +Question.defaultProps = { + overrideResponsive: false, +}; + export default Question; diff --git a/frontend/src/components/questions/QuestionAccordion.js b/frontend/src/components/questions/QuestionAccordion.js index 623a3da4..6e5c8b6e 100644 --- a/frontend/src/components/questions/QuestionAccordion.js +++ b/frontend/src/components/questions/QuestionAccordion.js @@ -1,13 +1,8 @@ import React from 'react'; -import { - Accordion, - AccordionSummary, - AccordionDetails, - Typography /*Button*/, -} from '@mui/material'; +import { Accordion, AccordionSummary, AccordionDetails, Typography, Button } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import makeStyles from '@mui/styles/makeStyles'; -// import LaunchIcon from '@mui/icons-material/Launch'; +import LaunchIcon from '@mui/icons-material/Launch'; import ReactMarkdown from 'react-markdown'; import remarkMath from 'remark-math'; import remarkKatex from 'rehype-katex'; @@ -68,11 +63,9 @@ const QuestionAccordion = ({ question, failed }) => { {correctAnswer.text} - {/* - - */} ); diff --git a/frontend/src/components/questions/QuestionForm.js b/frontend/src/components/questions/QuestionForm.js index bf6c882b..c3490b34 100644 --- a/frontend/src/components/questions/QuestionForm.js +++ b/frontend/src/components/questions/QuestionForm.js @@ -44,7 +44,19 @@ const initialState = { const QuestionForm = () => { const [submitted, setSubmitted] = useState(false); const [ - { subject, subSubject, text, year, image, correct, wrong1, wrong2, wrong3, source }, + { + subject, + subSubject, + text, + year, + image, + correct, + wrong1, + wrong2, + wrong3, + source, + resolution, + }, setState, ] = useState(initialState); const classes = useStyles(); @@ -81,6 +93,7 @@ const QuestionForm = () => { const handleSubmition = () => { const body = { text: text, + resolution: resolution, answers: [ { text: correct, @@ -255,6 +268,27 @@ const QuestionForm = () => { + + + + + + + + {resolution} + + + + ({ svg: { @@ -10,30 +13,47 @@ const useStyles = makeStyles(() => ({ }, text: { fontWeight: 'bold', - fontSize: 50, textAlign: 'start', - marginLeft: '6rem', + marginLeft: '3rem', marginTop: '12rem', }, })); const RegisterInfo = () => { const classes = useStyles(); + const windowArray = useWindowDimensions(); return ( - <> - - + + + Preparado para
subir notas?
- + {' '} - + - + ); }; diff --git a/frontend/src/components/register/RegisterInput.js b/frontend/src/components/register/RegisterInput.js index 3e51c346..0978c09f 100644 --- a/frontend/src/components/register/RegisterInput.js +++ b/frontend/src/components/register/RegisterInput.js @@ -7,25 +7,18 @@ import { ReactComponent as Logo } from '../../assets/logo_white.svg'; import NormalButton from '../buttons/NormalButton'; import CodeInput from './CodeInput'; import { useSnackbar } from 'notistack'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; +import responsiveWidth from '../../hooks/responsiveWidth'; +import responsiveHeight from '../../hooks/responsiveHeight'; const useStyles = makeStyles((theme) => ({ input: { backgroundColor: '#fff', borderRadius: 50, disableUnderline: true, - height: 65, - width: '22rem', - }, - container: { - width: '100%', - }, - containerForm: { - width: '100%', - marginTop: '5rem', }, resetPasswordText: { color: theme.palette.background.default, - fontSize: 18, '&:hover': { color: theme.palette.secondary.main, }, @@ -49,6 +42,7 @@ const RegisterInput = () => { const validUsername = new RegExp('^[a-zA-Z0-9._+-@]+$'); const validEmail = new RegExp('^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+[a-zA-Z0-9-.]+$'); const { enqueueSnackbar } = useSnackbar(); + const windowArray = useWindowDimensions(); const handleClick = () => { if (username === '' || email === '' || pass1 === '' || pass2 === '') @@ -145,30 +139,38 @@ const RegisterInput = () => { if (codePhase) return ; return ( - + - - + +
{ { { { onKeyUp={handleKeyPress} /> - - + +
- + Já tens conta? Faz Login diff --git a/frontend/src/components/report/Report.js b/frontend/src/components/report/Report.js new file mode 100644 index 00000000..a36f51f8 --- /dev/null +++ b/frontend/src/components/report/Report.js @@ -0,0 +1,82 @@ +import React from 'react'; +import { useState } from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + IconButton, + Collapse, +} from '@mui/material'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import { ReactComponent as DeleteReport } from '../../assets/deleteComment.svg'; +import { deleteReport } from '../../api'; + +const Report = (props) => { + const [expand, setExpand] = useState(false); + const row = props.data; + function handleReportDelete(id) { + deleteReport(id, () => { + props.afterReportDeleted(id); + }); + } + + return ( + <> + {' '} + + + {row.body && ( //Only shows arrow to open expandable if there is a body to show + setExpand(!expand)} + > + {expand ? : } + + )} + + + {Number(row.id)} + + + {row.question} + + {row.type} + {row.author} + {row.date} + + handleReportDelete(row.id)}> + + + + + {row.body && ( + + {' '} + {/* Expandable */} + + + + + + Descricão + + + + + {row.body} + + +
+
+
+
+ )} + + ); +}; + +export default Report; diff --git a/frontend/src/components/report/ReportTableHead.js b/frontend/src/components/report/ReportTableHead.js new file mode 100644 index 00000000..8d5e8431 --- /dev/null +++ b/frontend/src/components/report/ReportTableHead.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Box, TableCell, TableHead, TableRow, TableSortLabel } from '@mui/material'; + +import { visuallyHidden } from '@mui/utils'; + +const headCells = [ + { + id: 'id', + rightAlligned: false, + disablePadding: true, + label: 'ReportID', + }, + { + id: 'question', + rightAlligned: false, + disablePadding: false, + label: 'Questão', + }, + { + id: 'type', + rightAlligned: false, + disablePadding: false, + label: 'Tipo', + }, + { + id: 'author', + rightAlligned: false, + disablePadding: false, + label: 'Autor', + }, + { + id: 'date', + rightAlligned: false, + disablePadding: false, + label: 'Data', + }, + { + id: 'action', + rightAlligned: false, + disablePadding: false, + label: 'Ações', + }, +]; + +export default function EnhancedTableHead(props) { + const { order, orderBy, onRequestSort } = props; + const createSortHandler = (property) => (event) => { + onRequestSort(event, property); + }; + + return ( + + + + {headCells.map((headCell) => ( + + {headCell.id === 'body' || headCell.id === 'type' ? ( + headCell.label + ) : ( + + {headCell.label} + {orderBy === headCell.id ? ( + + {order === 'desc' + ? 'sorted descending' + : 'sorted ascending'} + + ) : null} + + )} + + ))} + + + ); +} + +EnhancedTableHead.propTypes = { + onRequestSort: PropTypes.func.isRequired, + order: PropTypes.oneOf(['asc', 'desc']).isRequired, + orderBy: PropTypes.string.isRequired, +}; diff --git a/frontend/src/components/report/ReportTableToolbar.js b/frontend/src/components/report/ReportTableToolbar.js new file mode 100644 index 00000000..84daaf71 --- /dev/null +++ b/frontend/src/components/report/ReportTableToolbar.js @@ -0,0 +1,195 @@ +import React from 'react'; +import { useState, useEffect } from 'react'; +import { + Toolbar, + Typography, + IconButton, + Tooltip, + Select, + MenuItem, + TextField, +} from '@mui/material'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import isUnique from '../../utils/isUnique'; +import SearchIcon from '@mui/icons-material/Search'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import CloseIcon from '@mui/icons-material/Close'; +import globalTheme from '../../globalTheme'; +import NormalButton from '../buttons/NormalButton'; +import maxNumOccurences from '../../utils/maxNumOccurences'; + +export default function ReportTableToolbar(props) { + const [reportIDs, setReportIDs] = useState([]); + const [questionIDs, setQuestionIDs] = useState([]); + const [types, setTypes] = useState([]); + const [reportID, setReportID] = useState('none'); + const [questionID, setQuestionID] = useState('none'); + const [chosenType, setChosenType] = useState('none'); + const [bodyQuery, setBodyQuery] = useState(''); + const [filtering, setFiltering] = useState(false); + + useEffect(() => { + const reportIDs = props.rows.map((row) => row['id']); + const uniqueReportIDs = reportIDs.filter(isUnique); + setReportIDs(uniqueReportIDs); + + const questionIDs = props.rows.map((row) => row['question']); + const uniqueQuestionIDs = questionIDs.filter(isUnique); + setQuestionIDs(uniqueQuestionIDs); + + const types = props.rows.map((row) => row['type']); + const uniqueTypes = types.filter(isUnique); + setTypes(uniqueTypes); + }, []); + + const handleReportIDChange = (event) => { + setReportID(event.target.value); + }; + + const handleQuestionIDChange = (event) => { + setQuestionID(event.target.value); + }; + + const handleErrorTypeChange = (event) => { + setChosenType(event.target.value); + }; + + const handleBodyQueryChange = (event) => { + setBodyQuery(event.target.value); + }; + + const search = () => { + props.searchFunction(reportID, questionID, chosenType, bodyQuery); + setBodyQuery(''); + }; + + const resetFilters = () => { + setReportID('none'); + setQuestionID('none'); + setChosenType('none'); + setBodyQuery(''); + props.resetFilters(); + }; + + const questionWithMoreReports = () => { + var questionIDs = props.rows.map((row) => row['question']); + var questionPrevalent = maxNumOccurences(questionIDs); + alert( + 'A questão com mais Problemas é a → ' + + String(questionPrevalent) + + '\n \n \n ლ(¯ロ¯"ლ) → (ノಥ益ಥ)ノ彡┻━┻' + ); + }; + return ( + + + Reports + + + {filtering ? ( + <> + {' '} + + + + + + + + + + + + + + + + ) : ( + + )} + {filtering ? ( + + setFiltering(!filtering)}> + + + + ) : ( + + setFiltering(!filtering)}> + + + + )} + + ); +} diff --git a/frontend/src/components/subject/SubjectInfoPanel.js b/frontend/src/components/subject/SubjectInfoPanel.js index b359cab2..5d52756e 100644 --- a/frontend/src/components/subject/SubjectInfoPanel.js +++ b/frontend/src/components/subject/SubjectInfoPanel.js @@ -2,6 +2,7 @@ import React from 'react'; import { useState } from 'react'; import makeStyles from '@mui/styles/makeStyles'; import { Grid, Select, MenuItem, Typography } from '@mui/material'; +import globalTheme from '../../globalTheme'; const useStyles = makeStyles(() => ({ select: { @@ -9,18 +10,6 @@ const useStyles = makeStyles(() => ({ '& .MuiSvgIcon-root': { color: '#EB5757', }, - '&.MuiOutlinedInput-notchedOutline': { - borderRadius: 0, - }, - 'MuiOutlinedInput-notchedOutline': { - border: 0, - }, - '&:active .MuiOutlinedInput-notchedOutline': { - border: 0, - }, - '&.Mui-focused .MuiOutlinedInput-notchedOutline': { - border: 0, - }, }, indexCircle: (index) => ({ backgroundColor: index > 80 ? '#00FF47' : index > 50 ? '#FFED47' : '#EB5757', @@ -86,6 +75,10 @@ const SubjectInfoPanel = ({ profile, changeSubject }) => { onChange={handleChange} disableUnderline className={classes.select} + sx={globalTheme.components.select.styleOverrides} + MenuProps={{ + sx: globalTheme.components.menuItem.styleOverrides, + }} > {profile.subjects.map((subj) => ( diff --git a/frontend/src/components/typographies/Body.js b/frontend/src/components/typographies/Body.js new file mode 100644 index 00000000..aa9db794 --- /dev/null +++ b/frontend/src/components/typographies/Body.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { Typography } from '@mui/material'; +import responsiveWidth from '../../hooks/responsiveWidth'; + +const Body = (props) => { + const sxStyles = { + Body: { + fontSize: responsiveWidth(undefined, 15, 25, 0.015), + }, + }; + return ( + + {props.children} + + ); +}; + +export default Body; diff --git a/frontend/src/components/typographies/Description.js b/frontend/src/components/typographies/Description.js new file mode 100644 index 00000000..62f4606f --- /dev/null +++ b/frontend/src/components/typographies/Description.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { Typography } from '@mui/material'; +import responsiveWidth from '../../hooks/responsiveWidth'; + +const Description = (props) => { + const sxStyles = { + DescriptiveText: { + fontWeight: 'normal', + fontSize: responsiveWidth(undefined, 10, 25, 0.012), + }, + }; + return ( + + {props.children} + + ); +}; + +export default Description; diff --git a/frontend/src/components/typographies/Subtitle.js b/frontend/src/components/typographies/Subtitle.js new file mode 100644 index 00000000..4015ccab --- /dev/null +++ b/frontend/src/components/typographies/Subtitle.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { Typography } from '@mui/material'; +import responsiveWidth from '../../hooks/responsiveWidth'; +import theme from '../../globalTheme'; + +const Subtitle = (props) => { + const sxStyles = { + Subtitle: { + fontWeight: 'bold', + fontSize: responsiveWidth(undefined, 20, 35, 0.017), + color: theme.palette.secondary.main, + }, + }; + return ( + + {props.children} + + ); +}; + +export default Subtitle; diff --git a/frontend/src/components/typographies/Title.js b/frontend/src/components/typographies/Title.js new file mode 100644 index 00000000..8f21eb86 --- /dev/null +++ b/frontend/src/components/typographies/Title.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { Typography } from '@mui/material'; +import responsiveWidth from '../../hooks/responsiveWidth'; + +const Title = (props) => { + const sxStyles = { + Title: { + paddingBottom: '3vh', + fontWeight: 'bold', + fontSize: responsiveWidth(undefined, 25, undefined, 0.027), + }, + }; + return ( + + {props.children} + + ); +}; + +export default Title; diff --git a/frontend/src/globalTheme.js b/frontend/src/globalTheme.js index 6f0efc87..abd9f84f 100644 --- a/frontend/src/globalTheme.js +++ b/frontend/src/globalTheme.js @@ -23,14 +23,17 @@ const globalTheme = createTheme( }, }, overrides: { - MuiSelect: { - '&.MuiOutlinedInput-notchedOutline': { - border: 0, + menuItem: { + '&& .Mui-selected': { + backgroundColor: '#D9D9D9', + }, + '&& .Mui-focusVisible': { + backgroundColor: 'transparent', }, }, - MuiOutlinedInput: { - notchedOutline: { - //border: 0 + select: { + '& .MuiOutlinedInput-notchedOutline': { + border: 'none', }, }, }, diff --git a/frontend/src/hooks/responsiveHeight.js b/frontend/src/hooks/responsiveHeight.js new file mode 100644 index 00000000..a5173454 --- /dev/null +++ b/frontend/src/hooks/responsiveHeight.js @@ -0,0 +1,19 @@ +import useWindowDimensions from './useWindowDimensions'; + +export default function responsiveHeight( + windowArray = useWindowDimensions(), + min = null, + max = null, + coef = 1 +) { + let size = coef * windowArray.height; + + if ((min !== null) & (size < min)) { + size = min; + } + if ((max !== null) & (size > max)) { + size = max; + } + + return size; +} diff --git a/frontend/src/hooks/responsiveWidth.js b/frontend/src/hooks/responsiveWidth.js new file mode 100644 index 00000000..ed48238a --- /dev/null +++ b/frontend/src/hooks/responsiveWidth.js @@ -0,0 +1,19 @@ +import useWindowDimensions from './useWindowDimensions'; + +export default function responsiveWidth( + windowArray = useWindowDimensions(), + min = null, + max = null, + coef = 1 +) { + let size = coef * windowArray.width; + + if ((min !== null) & (size < min)) { + size = min; + } + if ((max !== null) & (size > max)) { + size = max; + } + + return size; +} diff --git a/frontend/src/pages/AboutUsPage.js b/frontend/src/pages/AboutUsPage.js index 7a3b5ec9..79cca678 100644 --- a/frontend/src/pages/AboutUsPage.js +++ b/frontend/src/pages/AboutUsPage.js @@ -1,199 +1,185 @@ import React from 'react'; import { Grid, Paper, Typography, IconButton } from '@mui/material'; -import makeStyles from '@mui/styles/makeStyles'; import theme from '../globalTheme.js'; import { ReactComponent as Discord } from '../assets/icon_disc_3.svg'; import { ReactComponent as HackerSchool } from '../assets/icon_hs_3.svg'; import { ReactComponent as Instagram } from '../assets/icon_insta_1.svg'; import config from '../config'; - -const useStyles = makeStyles(() => ({ - rectangle: { - width: '100vw', - height: '30vh', - flexWrap: 'nowrap', - background: theme.palette.primary.main, - }, - paper: { - width: '105vh', - borderRadius: 20, - border: '3px solid #D9D9D9', - boxShadow: '-6px 4px 6px rgba(0, 0, 0, 0.25)', - marginTop: '-75px', - padding: '2rem', - }, - - text: { - wordWrap: 'break-word', - padding: '2rem', - }, - - body: { - minHeight: '12vh', - }, - - title: { - minHeight: '7vh', - }, - - name: { - wordWrap: 'break-word', - paddingLeft: '2rem', - }, - - hover: { - transition: 'transform 0.15s ease-in-out', - '&:hover': { - transform: 'scale(1.05,1.05)', - boxShadow: '0px 6px 4px #Bbb9b9', - }, - }, -})); +import Title from '../components/typographies/Title.js'; +import Body from '../components/typographies/Body.js'; +import responsiveWidth from '../hooks/responsiveWidth.js'; +import Description from '../components/typographies/Description'; export const AboutUsPage = () => { - const classes = useStyles(); + const sxStyles = { + rectangle: { + height: '30vh', + flexWrap: 'nowrap', + background: theme.palette.primary.main, + minHeight: '20vh', + }, + bigTitle: { + marginTop: '-7vh', + fontSize: responsiveWidth(undefined, 40, undefined, 0.035), + }, + paper: { + borderRadius: 20, + border: '3px solid #D9D9D9', + boxShadow: '-6px 4px 6px rgba(0, 0, 0, 0.25)', + padding: responsiveWidth(undefined, undefined, undefined, 0.002), + marginTop: '-7vh', + }, + text: { + wordWrap: 'break-word', + padding: '2rem', + }, + devName: { display: 'inline-block' }, + devSocials: { display: 'inline-block' }, + hover: { + width: responsiveWidth(undefined, undefined, 75, 0.13), + transition: 'transform 0.15s ease-in-out', + '&:hover': { + transform: 'scale(1.05,1.05)', + boxShadow: '0px 6px 4px #Bbb9b9', + }, + }, + }; return ( -
+ Quem somos... - - - - - - O Projeto - - - Esta plataforma foi desenvolvida com o objetivo de facilitar o - estudo a estudantes de ensino secundário, disponibilizando - ferramentas de análise automática (index de performance) e geração - personalizada de exames. - - - Arquimedia será sempre disponibilizada de forma gratuita a todos os - estudantes. - - - - - De estudantes para estudantes - - - Este projeto foi desenvolvido ao abrigo do núcleo Hackerschool do - Instituto Superior Técnico. - - - Até à data, contribuiram: - + + + {' '} + + - - {config.devs.map((dev) => { - return ( -
  • - - {dev.name} - {' '} - - {dev.socials.map((social) => { - return ( - - {social.component} - - ); - })} - -
  • - ); - })} -
    + + O Projeto + + Esta plataforma foi desenvolvida com o objetivo de facilitar + o estudo a estudantes de ensino secundário, disponibilizando + ferramentas de análise automática (index de performance) e + geração personalizada de exames. + + + Arquimedia será sempre disponibilizada de forma gratuita a + todos os estudantes. + + + + De estudantes para estudantes + + Este projeto foi desenvolvido ao abrigo do núcleo + Hackerschool do Instituto Superior Técnico. + + + Até à data, contribuiram: + + + + {config.devs.map((dev) => { + return ( +
  • + + {dev.name} + {' '} + {dev.socials.map((social) => { + return ( + + {social.component} + + ); + })} +
  • + ); + })} +
    +
    +
    -
    -
    - - - - Acompanha! - - - - - - - - - - - - - - - - + Acompanha! + + + - - - + + + + + + + + + + + + + + + +
    +
    - +
    -
    +
    ); }; diff --git a/frontend/src/pages/ExamPage.js b/frontend/src/pages/ExamPage.js index 140f6f38..94a02b4f 100644 --- a/frontend/src/pages/ExamPage.js +++ b/frontend/src/pages/ExamPage.js @@ -9,6 +9,8 @@ import Loading from '../components/loading/Loading'; import CustomizedSteppers from '../components/questions/Stepper'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import responsiveWidth from '../hooks/responsiveWidth'; +import useWindowDimensions from '../hooks/useWindowDimensions'; const COUNTDOWN_TIME = 60 * 45; @@ -67,6 +69,8 @@ const ExamPage = (props) => { const [loading, setLoading] = useState(true); const [currentQuestion, setCurrentQuestion] = useState(0); + const windowArray = useWindowDimensions(); + useEffect(() => { examInfo(props.match.params.id, (res) => { res.data.questions.forEach((question) => { @@ -96,54 +100,81 @@ const ExamPage = (props) => { if (loading) return ; return ( - - - setCurrentQuestion(index)} - submitExam={onComplete} - current={currentQuestion} - /> - - -
    - -
    -
    - - - + {' '} + + + + setCurrentQuestion(index)} + submitExam={onComplete} + current={currentQuestion} + /> + + +
    + +
    +
    + - -
    -
    - - - - - - - + + + + + + + + + + + + + + +
    + {currentQuestion === exam.questions.length - 1 && ( + + )} +
    -
    - {currentQuestion === exam.questions.length - 1 && ( - - )} -
    ); }; diff --git a/frontend/src/pages/GenExamPage.js b/frontend/src/pages/GenExamPage.js index d65a1357..bd05c5d9 100644 --- a/frontend/src/pages/GenExamPage.js +++ b/frontend/src/pages/GenExamPage.js @@ -25,6 +25,7 @@ import ArrowDropDownRoundedIcon from '@mui/icons-material/ArrowDropDownRounded'; import { useSnackbar } from 'notistack'; import { Checkbox } from '../components/checkbox/Checkbox'; import config from '../config'; +import globalTheme from '../globalTheme'; const useStyles = makeStyles((theme) => ({ body: {}, @@ -169,9 +170,8 @@ const GenExamPage = () => { const resetDictSubSubjects = () => { const newThemes = Object.fromEntries( + // eslint-disable-next-line no-unused-vars Object.entries(dictSubSubjects).map(([k, v], i) => { - console.log(v); - console.log(i); return [k, false]; }) ); @@ -270,206 +270,231 @@ const GenExamPage = () => { }; return ( - - - - - Personaliza o teu exame - - - - - {' '} - {/*Pick Subject*/} - - 1 - Disciplina - - - + {config.areas.map((area) => [ + {' '} - {el.name} - - )), - ])} - - - - - - {' '} - {/* Pick year*/} - - 2 - Ano(s) - - - - - - } - label={Aleatório} - /> - {config.subjects - .find((el) => el.name === subject) - .years.map((year) => ( + {area} + , + config.subjects + .filter((el) => el.area === area) + .map((el) => ( + + {' '} + {el.name} + + )), + ])} + + + + + + {' '} + {/* Pick year*/} + + 2 - Ano(s) + + + + } - label={ - - {String(year) + 'º'} - - } - /> - ))} - - - - - - - {' '} - {/*Pick Themes*/} - - 3 - Tópicos - - - - - Aleatório} /> - } - label={Aleatório} - /> - {config.subjects - .find((el) => el.name === subject) - .themes.map((theme) => ( + {config.subjects + .find((el) => el.name === subject) + .years.map((year) => ( + + } + label={ + + {String(year) + 'º'} + + } + /> + ))} + + + + + + + {' '} + {/*Pick Themes*/} + + 3 - Tópicos + + + + } - label={{theme}} + label={Aleatório} /> - ))} - - + {config.subjects + .find((el) => el.name === subject) + .themes.map((theme) => ( + + } + label={ + + {theme} + + } + /> + ))} + + + + + + + {' '} + {/*Começar Button*/} + + + + - - - {' '} - {/*Começar Button*/} - - + + + ou + + + + + + + Deixa isso connosco + + + + + Aqui ficamos responsáveis por gerar o{' '} + + melhor exame para ti + + , tendo em conta as tuas últimas performances. + {' '} + {/* Best way to change a specific attribute in a string */} + + + }> + {config.subjects + .filter((subject) => subject.active) + .map((subject) => ( + + handleClickRecommended(subject)} + > + + {subject.name} + + } + /> + + + + + + ))} + + - - - - ou - - - - - - - Deixa isso connosco - - - - - Aqui ficamos responsáveis por gerar o{' '} - - melhor exame para ti - - , tendo em conta as tuas últimas performances. - {' '} - {/* Best way to change a specific attribute in a string */} - - - }> - {config.subjects - .filter((subject) => subject.active) - .map((subject) => ( - - handleClickRecommended(subject)}> - {subject.name} - } - /> - - - - - - ))} - - -
    ); }; diff --git a/frontend/src/pages/HomePage.js b/frontend/src/pages/HomePage.js index 8da6d560..d32eb434 100644 --- a/frontend/src/pages/HomePage.js +++ b/frontend/src/pages/HomePage.js @@ -1,195 +1,195 @@ import React from 'react'; -import { Grid, Paper, Typography, ListItem, List } from '@mui/material'; -import makeStyles from '@mui/styles/makeStyles/'; -import theme from '../globalTheme'; +import { Grid, Typography, ListItem, List } from '@mui/material'; import IconButton from '../components/buttons/IconButton'; import { ReactComponent as ExamIcon } from '../assets/examIcon.svg'; import { ReactComponent as ProfileIcon } from '../assets/profileIcon.svg'; import { ReactComponent as LeaderboardIcon } from '../assets/leaderboardIcon.svg'; import { ReactComponent as AnswersIcon } from '../assets/answersIcon.svg'; import { ReactComponent as DiscordIcon } from '../assets/icons8-discord-new-96.svg'; - -const useStyles = makeStyles(() => ({ - paper: { - width: '88%', - borderRadius: 40, - border: '3px solid #D9D9D9', - boxShadow: '-6px 4px 6px rgba(0, 0, 0, 0.25)', - margin: '0 auto', - }, - container: { - width: '100%', - height: '100%', - padding: '3rem', - }, - icon: { - fontSize: 100, - }, - bold: { - fontWeight: 600, - }, - futureText: { - fontWeight: 500, - margin: '0px 10px', - }, -})); +import useWindowDimensions from '../hooks/useWindowDimensions'; +import responsiveWidth from '../hooks/responsiveWidth'; +import Box from '../components/Box/Box'; export const HomePage = () => { - const classes = useStyles(); + const windowArray = useWindowDimensions(); - return ( - - - - - {' '} - O que fazer ... - - + const sxStyles = { + paper: { + borderRadius: 40, + border: '3px solid #D9D9D9', + boxShadow: '-6px 4px 6px rgba(0, 0, 0, 0.25)', + margin: '0 auto', + }, + container: { + padding: '3rem', + }, + bold: { + fontWeight: 600, + }, + futureText: { + fontWeight: 500, + margin: '0px 10px', + }, + homepageButton: { + height: { + md: responsiveWidth(windowArray, 150, undefined, 0.12), + sm: responsiveWidth(windowArray, 195, undefined, 0.25), + xs: responsiveWidth(windowArray, 160, undefined, 0.5), + }, + width: { + md: responsiveWidth(windowArray, 135, undefined, 0.11), + sm: responsiveWidth(windowArray, 180, undefined, 0.25), + xs: responsiveWidth(windowArray, 160, undefined, 0.5), + }, + }, + icon: { + width: responsiveWidth(windowArray, 40, undefined, 0.03), + }, - {/* Home Buttons */} - + homebuttonsArray: { margin: '20px 0px' }, + }; + return ( + + {' '} + + {' '} + - - - - + + + {' '} + O que fazer ... + - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + {' '} + O que estamos a preparar para ti ... + - - - - - - - - - + + + + + {' '} + • Resolução de Perguntas + + + + + {' '} + • Análise do Estudo + + + + + {' '} + • Interacção com os teus amigos + + + + + {' '} + • Fornadas de Perguntas 🍞 e mais disciplinas 📚 + + + + + {' '} + • Troféus 🏆 + + + - - - - {' '} - O que estamos a preparar para ti ... - - - - - - - {' '} - • Resolução de Perguntas - - - - - {' '} - • Análise do Estudo - - - - - {' '} - • Interacção com os teus amigos - - - - - {' '} - • Fornadas de Perguntas 🍞 e mais disciplinas 📚 - - - - - {' '} - • Troféus 🏆 - - - - + - +
    ); }; diff --git a/frontend/src/pages/LandingPage.js b/frontend/src/pages/LandingPage.js index d84f82d8..ea87de36 100644 --- a/frontend/src/pages/LandingPage.js +++ b/frontend/src/pages/LandingPage.js @@ -6,13 +6,12 @@ import { ReactComponent as Emoji } from '../assets/winking_emoji.svg'; import NormalButton from '../components/buttons/NormalButton'; import { userContext } from '../context/UserContextProvider'; import { HomePage } from './HomePage'; +import useWindowDimensions from '../hooks/useWindowDimensions'; +import responsiveWidth from '../hooks/responsiveWidth'; +import responsiveHeight from '../hooks/responsiveHeight'; const useStyles = makeStyles(() => ({ - mainBox: { - marginTop: '5vh', - }, slogan: { - fontSize: 55, textAlign: 'center', }, girl: { @@ -23,6 +22,7 @@ const useStyles = makeStyles(() => ({ const LandingPage = () => { const classes = useStyles(); const [user, loading] = useContext(userContext); + const windowArray = useWindowDimensions(); if (!loading && user !== null) { return ; @@ -30,29 +30,64 @@ const LandingPage = () => { return ( !loading && ( - - - - - Exames nacionais -
    - made easy -
    -
    - - + + {' '} + + + + + + Exames nacionais +
    + made easy{' '} + +
    +
    + + + +
    + + +
    - - -
    ) ); diff --git a/frontend/src/pages/LeaderboardPage.js b/frontend/src/pages/LeaderboardPage.js index 0241705a..58d116ea 100644 --- a/frontend/src/pages/LeaderboardPage.js +++ b/frontend/src/pages/LeaderboardPage.js @@ -82,38 +82,47 @@ function LeaderboardPage() { )); return ( - - - Leaderboard {span === 'sempre' ? 'de' : 'do'} - - - - {renderLeaderboard} - - - + + {' '} + + + + Leaderboard {span === 'sempre' ? 'de' : 'do'} + + + + {renderLeaderboard} + + + + + - +
    ); } diff --git a/frontend/src/pages/LoginPage.js b/frontend/src/pages/LoginPage.js index e92f9c79..77556982 100644 --- a/frontend/src/pages/LoginPage.js +++ b/frontend/src/pages/LoginPage.js @@ -3,10 +3,11 @@ import { Grid, Typography } from '@mui/material'; import makeStyles from '@mui/styles/makeStyles'; import LoginInput from '../components/login/LoginInput'; import { ReactComponent as Girl } from '../assets/girl_laid.svg'; +import useWindowDimensions from '../hooks/useWindowDimensions'; +import responsiveWidth from '../hooks/responsiveWidth'; const useStyles = makeStyles(() => ({ container: { - height: '100vh', border: 3, boxSizing: 'border-box', }, @@ -19,15 +20,15 @@ const useStyles = makeStyles(() => ({ }, text: { fontWeight: 'bold', - fontSize: 50, textAlign: 'start', - marginLeft: '6rem', - marginTop: '12rem', + marginLeft: '3rem', + marginTop: '6rem', }, })); const LoginPage = () => { const classes = useStyles(); + const windowArray = useWindowDimensions(); return ( { container direction='row' align='center' + justifyContent='space-between' alignItems='stretch' > { direction='column' justifyContent='space-between' alignItems='flex-start' + style={{ height: windowArray.height }} > - - + + Um exame por dia,
    não sabes o bem que te fazia!
    - + {' '} - + - + diff --git a/frontend/src/pages/ProfilePage.js b/frontend/src/pages/ProfilePage.js index 9bf36ecf..c0b866b5 100644 --- a/frontend/src/pages/ProfilePage.js +++ b/frontend/src/pages/ProfilePage.js @@ -40,39 +40,42 @@ const ProfilePage = () => { if (loading) return ; return ( - - - {' '} - {/* General Info */} - - - + + {' '} + + + {' '} + {/* General Info */} + + + + + + {' '} + {/* XP Graph */} + + + + + + {' '} + {/* Subject info */} + + + + + + {' '} + {/* Subject Achievements Info */} + + + + + - - {' '} - {/* XP Graph */} - - - - - - {' '} - {/* Subject info */} - - - - - - {' '} - {/* Subject Achievements Info */} - - - - - ); }; diff --git a/frontend/src/pages/QuestionPage.js b/frontend/src/pages/QuestionPage.js index f84551eb..6cd621de 100644 --- a/frontend/src/pages/QuestionPage.js +++ b/frontend/src/pages/QuestionPage.js @@ -1,345 +1,393 @@ -import React, { Component } from 'react'; -import { Typography, Grid, TextField, Button, Paper, Divider } from '@mui/material'; -import AvatarUser from '../components/avatar/AvatarUser'; -import { Delete, ArrowUpward, ArrowDownward } from '@mui/icons-material'; +import React, { useEffect, useState } from 'react'; import { - questionInfo, - getUser, - hasDownvotedAPI, - hasUpvotedAPI, - upvoteAPI, - downvoteAPI, - deleteCommentAPI, - removeDownvoteAPI, - removeUpvoteAPI, - createCommentAPI, -} from '../api'; -import $ from 'jquery'; + Grid, + Typography, + Accordion, + AccordionSummary, + AccordionDetails, + IconButton, + MenuItem, + Select, +} from '@mui/material'; +import { useParams } from 'react-router-dom'; +import Question from '../components/questions/Question'; +import { fetchQuestion, getUser, getProfile, createReport } from '../api'; +import theme from '../globalTheme'; +import Box from '../components/Box/Box'; import ReactMarkdown from 'react-markdown'; import remarkMath from 'remark-math'; import remarkKatex from 'rehype-katex'; import remarRehype from 'remark-rehype'; +import VideoLibraryIcon from '@mui/icons-material/VideoLibrary'; +import ArticleIcon from '@mui/icons-material/Article'; +import useWindowDimensions from '../hooks/useWindowDimensions'; +import responsiveWidth from '../hooks/responsiveWidth'; +import { Chat } from '../components/chat/Chat'; +import ReportDialog from '../components/dialogs/ReportDialog'; +import { useSnackbar } from 'notistack'; +import { ReactComponent as MessageReport } from '../assets/messageReport.svg'; +import isSwear from '../utils/isSwear'; +import globalTheme from '../globalTheme'; +import Loading from '../components/loading/Loading'; +import Subtitle from '../components/typographies/Subtitle'; +import Description from '../components/typographies/Description'; -// Renders a page about a specific Question and allows for comments on it -export default class QuestionPage extends Component { - constructor(props) { - super(props); - this.ID = this.props.match.params.id; - } +const iconSelector = { + video: , + paper: , +}; - render() { - return ( - - - - - - ); +function descendingComparator(a, b, orderBy) { + // We assume that values are descending + if (Number(b[orderBy]) < Number(a[orderBy])) { + // B Number(a[orderBy])) { + // B>A logo B vai aparecer primeiro que A + return 1; } + return 0; } -// Displays information about a Question -class QuestionInfo extends Component { - constructor(props) { - super(props); - this.state = { - text: '', - subject: '', - year: '', - difficulty: '', - image: '', - comment: [], - currentUser: 0, - }; - this.getQuestionInfo = this.getQuestionInfo.bind(this); - this.addComment = this.addComment.bind(this); - this.removeComment = this.removeComment.bind(this); - this.getCurrentUser = this.getCurrentUser.bind(this); - this.currentUser = this.getCurrentUser(); - this.getQuestionInfo(); - } +// This method is created for cross-browser compatibility, if you don't +// need to support IE11, you can use Array.prototype.sort() directly +function stableSort(array, comparator) { + const stabilizedThis = array.map((el, index) => [el, index]); //Stores each object and it's index prior to sorting + stabilizedThis.sort((a, b) => { + //Sorts according to a comparator Function (if it returns value > 0 sort A after B) (if it returns value < 0 sort B after A) + //(If they are the same, the old indexes decide, and the same sorting is mantained for that case) + const order = comparator(a[0], b[0]); + if (order !== 0) { + return order; + } + return a[1] - b[1]; + }); + return stabilizedThis.map((el) => el[0]); + //Eliminates old index, makes it only one element again, only store the first element of the array which is the initial object +} - getCurrentUser() { - getUser((res) => { - this.setState({ currentUser: res.data.id }); - }); - } +export default function QuestionPage() { + const { id } = useParams(); + const { enqueueSnackbar } = useSnackbar(); + const [question, setQuestion] = useState(null); + const [userID, setUserID] = useState(null); + const [isUserMod, setIsUserMod] = useState(false); + const [open, setOpen] = useState(false); + const [sortingType, setSortingType] = useState('votes'); + const [comments, setComments] = useState([]); - getQuestionInfo() { - questionInfo(this.props.ID, (res) => { - this.setState({ - text: res.data.text, - subject: res.data.subject, - year: res.data.year, - difficulty: res.data.difficulty, - image: res.data.image, - comment: res.data.comment, - }); - }); - } + const windowArray = useWindowDimensions(); - addComment(data) { - let newComments = this.state.comment.slice(); - newComments.push(data); - this.setState({ - comment: newComments, - }); - } + const sxStyles = { + sorterText: { fontSize: responsiveWidth(windowArray, 13, 20, 0.01) }, + responsiveIcons: { width: responsiveWidth(windowArray, 20, 40, 0.025), height: 'auto' }, + itemInfoLabel: { + fontWeight: 'bold', + fontSize: responsiveWidth(windowArray, 10, 30, 0.012), + }, + box: { + border: '2px solid', + borderRadius: 5, + borderColor: theme.palette.grey.primary, + boxShadow: '-6px 7px 16px rgba(0, 0, 0, 0.25)', + padding: '1rem', + }, + resource: { + '&:hover': { + color: theme.palette.secondary.main, + }, + color: 'black', + marginLeft: '1rem', + }, + resourceGrid: { + '&:hover': { + color: theme.palette.secondary.main, + }, + }, + }; - removeComment(data) { - let newComments = this.state.comment.filter((el) => el.id !== data.id); - this.setState({ - comment: newComments, - }); - } + const changeSorting = () => { + if (sortingType === 'votes') { + setSortingType('id'); + } else { + setSortingType('votes'); + } + }; - render() { - return ( -
    - - - - - {this.state.text} - - - - - visual support - - - {this.state.subject} - - - {this.state.year} - - - {this.state.difficulty} - - - {/* Comments */} -
    - - {this.state.comment.map((comment) => ( -
    - this.removeComment(data)} - /> - {comment !== this.state.comment[this.state.comment.length - 1] && ( - - )} -
    - ))} -
    -
    - this.addComment(data)} - /> -
    + const sortRerender = (newComments = comments) => { + setComments( + stableSort(newComments, (a, b) => descendingComparator(a, b, sortingType)).map( + (comment) => comment + ) ); - } -} - -class Comment extends Component { - constructor(props) { - super(props); - this.state = { - upvoted: false, - downvoted: false, - votes: this.props.data.votes, - }; - this.deleteComment = this.deleteComment.bind(this); - this.upvote = this.upvote.bind(this); - this.removeUpvote = this.removeUpvote.bind(this); - this.downvote = this.downvote.bind(this); - this.removeDownvote = this.removeDownvote.bind(this); - this.hasUpvoted = this.hasUpvoted.bind(this); - this.hasDownvoted = this.hasDownvoted.bind(this); - this.hasUpvoted(); - this.hasDownvoted(); - this.csrftoken = getCookie('csrftoken'); - } - - hasDownvoted() { - hasDownvotedAPI(this.props.data.id, () => this.setState({ downvoted: true })); - } - - hasUpvoted() { - hasUpvotedAPI(this.props.data.id, () => this.setState({ upvoted: true })); - } - - deleteComment() { - deleteCommentAPI(this.props.data.id, () => { - this.props.deleteCommentFun(this.props.data); - }); - } + }; - upvote() { - if (!this.state.upvoted) { - upvoteAPI(this.props.data.id, (res) => { - this.setState({ - upvoted: true, - downvoted: false, - votes: res.data.votes, + const handleClickOpen = () => { + setOpen(true); + }; + // eslint-disable-next-line no-unused-vars + const reportCreator = (reportType, otherDescription) => { + let body = ''; + if (otherDescription !== null) { + body = { question: id, type: reportType, body: otherDescription }; + } else { + body = { question: id, type: reportType }; + } + createReport( + body, + (res) => { + enqueueSnackbar( + 'Foi criado com sucesso a sua denúncia com id:' + String(res.data.id), + { variant: 'success' } + ); + }, + () => { + enqueueSnackbar('Não foi possível criar a sua denúncia, tente mais tarde...', { + variant: 'error', }); - }); - } else this.removeUpvote(); - } + } + ); + }; + const onClose = (reportType, otherDescription) => { + // We only want to submit stuff when the user chooses a report Type, and in the case of the Other there must be some description + // If there is some description regardless of report type it must have at least 30 characters + setOpen(false); - removeUpvote() { - removeUpvoteAPI(this.props.data.id, (res) => { - this.setState({ - upvoted: false, - votes: res.data.votes, + if (reportType === 'Other' && otherDescription === null) { + enqueueSnackbar( + "Submissão Inválida! Se escolheu 'Outro...', precisa de clarificar o problema encontrado...", + { + variant: 'error', + } + ); + } else if ( + reportType !== null && + otherDescription !== null && + String(otherDescription).length < 30 + ) { + enqueueSnackbar('A sua descrição necessita de um mínimo de 30 caractéres', { + variant: 'error', }); - }); - } - - downvote() { - if (!this.state.downvoted) { - downvoteAPI(this.props.data.id, (res) => { - this.setState({ - downvoted: true, - upvoted: false, - votes: res.data.votes, - }); + return; + } else if (reportType !== null && isSwear(otherDescription)) { + enqueueSnackbar('A sua descrição contém expressões agressivas/impróprias', { + variant: 'warning', }); - } else this.removeDownvote(); - } - - removeDownvote() { - removeDownvoteAPI(this.props.data.id, (res) => { - this.setState({ - downvoted: false, - votes: res.data.votes, + return; + } else if (reportType !== null) { + reportCreator(reportType, otherDescription); + } + }; + useEffect(() => { + if (question) { + sortRerender(comments); + } else { + fetchQuestion(id, (res) => { + setQuestion(res.data); + sortRerender(res.data.comment); }); - }); - } - - render() { - return ( -
    - - - - {this.state.votes} - - - - {this.props.data.author.username} - - - - - {this.props.data.content} - - - - {this.props.data.date} - - - - - - - {this.props.currentUser === this.props.data.author.id && ( - - )} - - -
    - ); - } -} - -class CommentInputBox extends Component { - constructor(props) { - super(props); - this.state = { - content: '', - }; - this.handleCommentSubmissionChange = this.handleCommentSubmissionChange.bind(this); - this.createComment = this.createComment.bind(this); - } + } + }, [sortingType]); - createComment() { - if (this.state.content !== '') { - const body = { - content: this.state.content, - author: { id: 0 }, - votes: 2, - question: { id: this.props.questionID }, - }; + useEffect(() => { + getUser((res1) => { + if (res1.data?.mod) { + setIsUserMod(true); + } - createCommentAPI(body, (res) => { - this.props.addComment(res.data); - document.getElementById('commentContent').value = ''; - this.setState({ content: '' }); + getProfile(res1.data.profile, (res2) => { + setUserID(res2.data.user.id); }); - } else alert('Escreve algo primeiro!'); - } - - handleCommentSubmissionChange(e) { - this.setState({ - content: e.target.value, }); - } + }, []); - render() { + if (question === null) { return ( - - - Escreve um comentário: - - - - - - - - - - - + + ); } -} + return ( + + {' '} + + + {/* Question box */} + + {' '} + {/* //So that the number we see in the question matches the id, since the answer prop was made for the specific case of exams, + it adds one more so that an exam does not start in 0. So this is a adaptation */} + + {/* Question info box */} + + + + + Detalhes + + + + Disciplina:{' '} + {question.subject} + + + + {' '} + + Tema:{' '} + {question.subsubject} + + + + + Ano:{' '} + {question.year} + + + + + Fonte:{' '} + {question.source} + + + + + Autor:{' '} + + + -function getCookie(name) { - var cookieValue = null; - if (document.cookie && document.cookie !== '') { - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var cookie = $.trim(cookies[i]); - if (cookie.substring(0, name.length + 1) === name + '=') { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; + + {' '} + + + + Reportar Erro + + + + + + + {/* Resolution */} + + + Resolução + {question.resolution ? ( + + + {question.resolution} + + + ) : ( + + Ainda estamos a trabalhar na resolução desta pergunta... + + )} + + + {/* Resources */} + + + + + Recursos + + + {question.resources.length > 0 ? ( + <> + {question.resources.map((resource) => ( + + {iconSelector[resource.type]} + +
    + {resource.description} + + +
    + ))} + + ) : ( + + Ainda estamos à procura de recursos... + + )} + + +
    + {/* Comments */} + + + + + + + + + {/* List of Comments Area */} + + + + +
    +
    +
    + ); } diff --git a/frontend/src/pages/RegistrationPage.js b/frontend/src/pages/RegistrationPage.js index 8aa8e020..751f7f39 100644 --- a/frontend/src/pages/RegistrationPage.js +++ b/frontend/src/pages/RegistrationPage.js @@ -6,7 +6,6 @@ import makeStyles from '@mui/styles/makeStyles'; const useStyles = makeStyles(() => ({ container: { - height: '100vh', border: 3, boxSizing: 'border-box', }, @@ -24,18 +23,19 @@ const RegistrationPage = () => { container direction='row' align='center' + justifyContent='space-between' alignItems='stretch' > + + - - -
    diff --git a/frontend/src/pages/ReportsPage.js b/frontend/src/pages/ReportsPage.js new file mode 100644 index 00000000..146f806f --- /dev/null +++ b/frontend/src/pages/ReportsPage.js @@ -0,0 +1,180 @@ +import React from 'react'; +import { useState, useEffect } from 'react'; +import { Box, Table, TableBody, TableContainer, TablePagination, Paper, Grid } from '@mui/material'; +import { getReports } from '../api'; +import Loading from '../components/loading/Loading'; +import Report from '../components/report/Report'; +import ReportTableHead from '../components/report/ReportTableHead'; +import ReportTableToolbar from '../components/report/ReportTableToolbar'; + +function descendingComparator(a, b, orderBy) { + // We assume that values are descending + if (Number(b[orderBy]) < Number(a[orderBy])) { + // B Number(a[orderBy])) { + // B>A logo B vai aparecer primeiro que A + return 1; + } + return 0; +} + +function getComparator(order, orderBy) { + // Changes the Signal of the comparator function, depending on if we want and ascending or descending relationship, default is the latter + return order === 'desc' + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +// This method is created for cross-browser compatibility, if you don't +// need to support IE11, you can use Array.prototype.sort() directly +function stableSort(array, comparator) { + const stabilizedThis = array.map((el, index) => [el, index]); //Stores each object and it's index prior to sorting + stabilizedThis.sort((a, b) => { + //Sorts according to a comparator Function (if it returns value > 0 sort A after B) (if it returns value < 0 sort B after A) + //(If they are the same, the old indexes decide, and the same sorting is mantained for that case) + const order = comparator(a[0], b[0]); + if (order !== 0) { + return order; + } + return a[1] - b[1]; + }); + return stabilizedThis.map((el) => el[0]); + //Eliminates old index, makes it only one element again, only store the first element of the array which is the initial object +} + +const ReportsPage = () => { + const [order, setOrder] = useState('asc'); + const [orderBy, setOrderBy] = useState('id'); + const [page, setPage] = useState(0); + const [rows, setRows] = useState(null); + const [rowsPerPage, setRowsPerPage] = useState(5); + const [loading, setLoading] = useState(true); + const [originalRows, setOriginalRows] = useState([]); + + useEffect(() => { + getReports((res) => { + setOriginalRows(res.data); + setRows(res.data); + setLoading(false); + }); + }, []); + const afterReportDeleted = (id) => { + const reports = rows.filter((report) => report.id !== id); + setRows(reports); //Update List + }; + + const handleRequestSort = (event, property) => { + const isAsc = orderBy === property && order === 'asc'; + //We ought to know if the event is happening on the same property, if so the order will be inversed + setOrder(isAsc ? 'desc' : 'asc'); //if it was previously ascending in the same porperty we now change for descending + setOrderBy(property); + }; + + const handleChangePage = (event, newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const search = (reportID, questionID, chosenType, bodyQuery) => { + var reports = originalRows; + if (reportID !== 'none') { + //The journey ends here + reports = reports.filter((report) => reportID === report['id']); + } else { + if (questionID !== 'none') { + reports = reports.filter((report) => questionID === report['question']); + } + if (chosenType !== 'none') { + reports = reports.filter((report) => chosenType === report['type']); + } + if (bodyQuery.length > 0) { + reports = reports + .filter((report) => report['body']) //filter only rows with a body !== null + .filter((report) => { + return report['body'].toLowerCase().includes(bodyQuery.toLowerCase()); + }); //Search is case insentive + } + } + + setRows(reports); + }; + const resetRows = () => { + setRows(originalRows); + }; + + if (loading) return ; + + return ( + + {' '} + + + + + + + + + + + + + + + + + + {/* if you don't need to support IE11, you can replace the `stableSort` call with: + rows.slice().sort(getComparator(order, orderBy)) */} + {stableSort(rows, getComparator(order, orderBy)) + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) //What objects will be shown + .map((row, index) => { + const labelId = `report-table-checkbox-${index}`; + + return ( + + ); + })} + +
    +
    + +
    +
    +
    +
    + ); +}; +export default ReportsPage; diff --git a/frontend/src/pages/ResultsPage.js b/frontend/src/pages/ResultsPage.js index 9ccda11b..dc20761c 100644 --- a/frontend/src/pages/ResultsPage.js +++ b/frontend/src/pages/ResultsPage.js @@ -43,66 +43,71 @@ const ResultsPage = (props) => { if (loading) return ; return ( - - - - Resultados - - - - - - - - {' '} - ✅ Corretas: {exam.correct.length} - + + {' '} + + + + + Resultados + + + + + + + + {' '} + ✅ Corretas: {exam.correct.length} + + + + 🏆 Nota: {exam.score} / 200 + + + + {' '} + ❌ Erradas: {exam.failed.length} + + + - - 🏆 Nota: {exam.score} / 200 + + + Perguntas: + + + + + + + + - - - {' '} - ❌ Erradas: {exam.failed.length} - - - - - - - Perguntas: - - - - - - - - +
    -
    +
    ); }; diff --git a/frontend/src/pages/SettingsPage.js b/frontend/src/pages/SettingsPage.js index 10e1d926..0857cb5f 100644 --- a/frontend/src/pages/SettingsPage.js +++ b/frontend/src/pages/SettingsPage.js @@ -19,18 +19,17 @@ import config from '../config'; import { TextInput } from '../components/inputs/TextInput'; import { useSnackbar } from 'notistack'; import { changePassword, deleteAccount } from '../api'; +import useWindowDimensions from '../hooks/useWindowDimensions'; +import responsiveWidth from '../hooks/responsiveWidth'; const useStyles = makeStyles(() => ({ paper: { - width: '100%', - height: '80vh', borderRadius: 40, border: '3px solid #D9D9D9', boxShadow: '-6px 4px 6px rgba(0, 0, 0, 0.25)', + marginBottom: '1rem', }, container: { - width: '100%', - height: '100%', padding: '3rem', }, settings: { @@ -80,6 +79,7 @@ export const SettingsPage = () => { const [newPassword, setNewPassword] = useState(''); const [newPasswordRep, setNewPasswordRep] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); + const windowArray = useWindowDimensions(); const handleDelete = () => { deleteAccount( @@ -89,7 +89,6 @@ export const SettingsPage = () => { window.location.replace('/'); }, (error) => { - console.log(error.response); if ( error.response.data.non_field_errors && error.response.data.non_field_errors[0] === @@ -312,20 +311,28 @@ export const SettingsPage = () => { }; return ( - - - - {/* Menu */} - {renderMenu()} - + + + + + {/* Menu */} + {renderMenu()} + - + - - {/* Settings */} - {menuOption === 'Conta' ? renderAccountSettings() : renderPrivacySettings()} + + {/* Settings */} + {menuOption === 'Conta' ? renderAccountSettings() : renderPrivacySettings()} + - - + +
    ); }; diff --git a/frontend/src/utils/badWords.json b/frontend/src/utils/badWords.json new file mode 100644 index 00000000..fd5cc358 --- /dev/null +++ b/frontend/src/utils/badWords.json @@ -0,0 +1,505 @@ +{ + "words": [ + "ahole", + "anus", + "ash0le", + "ash0les", + "asholes", + "ass", + "Ass Monkey", + "Assface", + "assh0le", + "assh0lez", + "asshole", + "assholes", + "assholz", + "asswipe", + "azzhole", + "bassterds", + "bastard", + "bastards", + "bastardz", + "basterds", + "basterdz", + "Biatch", + "bitch", + "bitches", + "Blow Job", + "boffing", + "butthole", + "buttwipe", + "c0ck", + "c0cks", + "c0k", + "Carpet Muncher", + "cawk", + "cawks", + "Clit", + "cnts", + "cntz", + "cock", + "cockhead", + "cock-head", + "cocks", + "CockSucker", + "cock-sucker", + "crap", + "cum", + "cunt", + "cunts", + "cuntz", + "dick", + "dild0", + "dild0s", + "dildo", + "dildos", + "dilld0", + "dilld0s", + "dominatricks", + "dominatrics", + "dominatrix", + "dyke", + "enema", + "f u c k", + "f u c k e r", + "fag", + "fag1t", + "faget", + "fagg1t", + "faggit", + "faggot", + "fagg0t", + "fagit", + "fags", + "fagz", + "faig", + "faigs", + "fart", + "flipping the bird", + "fuck", + "fucker", + "fuckin", + "fucking", + "fucks", + "Fudge Packer", + "fuk", + "Fukah", + "Fuken", + "fuker", + "Fukin", + "Fukk", + "Fukkah", + "Fukken", + "Fukker", + "Fukkin", + "g00k", + "God-damned", + "h00r", + "h0ar", + "h0re", + "hells", + "hoar", + "hoor", + "hoore", + "jackoff", + "jap", + "japs", + "jerk-off", + "jisim", + "jiss", + "jizm", + "jizz", + "knob", + "knobs", + "knobz", + "kunt", + "kunts", + "kuntz", + "Lezzian", + "Lipshits", + "Lipshitz", + "masochist", + "masokist", + "massterbait", + "masstrbait", + "masstrbate", + "masterbaiter", + "masterbate", + "masterbates", + "Motha Fucker", + "Motha Fuker", + "Motha Fukkah", + "Motha Fukker", + "Mother Fucker", + "Mother Fukah", + "Mother Fuker", + "Mother Fukkah", + "Mother Fukker", + "mother-fucker", + "Mutha Fucker", + "Mutha Fukah", + "Mutha Fuker", + "Mutha Fukkah", + "Mutha Fukker", + "n1gr", + "nastt", + "nigger;", + "nigur;", + "niiger;", + "niigr;", + "orafis", + "orgasim;", + "orgasm", + "orgasum", + "oriface", + "orifice", + "orifiss", + "packi", + "packie", + "packy", + "paki", + "pakie", + "paky", + "pecker", + "peeenus", + "peeenusss", + "peenus", + "peinus", + "pen1s", + "penas", + "penis", + "penis-breath", + "penus", + "penuus", + "Phuc", + "Phuck", + "Phuk", + "Phuker", + "Phukker", + "polac", + "polack", + "polak", + "Poonani", + "pr1c", + "pr1ck", + "pr1k", + "pusse", + "pussee", + "pussy", + "puuke", + "puuker", + "qweir", + "recktum", + "rectum", + "retard", + "sadist", + "scank", + "schlong", + "screwing", + "semen", + "sex", + "sexy", + "Sh!t", + "sh1t", + "sh1ter", + "sh1ts", + "sh1tter", + "sh1tz", + "shit", + "shits", + "shitter", + "Shitty", + "Shity", + "shitz", + "Shyt", + "Shyte", + "Shytty", + "Shyty", + "skanck", + "skank", + "skankee", + "skankey", + "skanks", + "Skanky", + "slag", + "slut", + "sluts", + "Slutty", + "slutz", + "son-of-a-bitch", + "tit", + "turd", + "va1jina", + "vag1na", + "vagiina", + "vagina", + "vaj1na", + "vajina", + "vullva", + "vulva", + "w0p", + "wh00r", + "wh0re", + "whore", + "xrated", + "xxx", + "b!+ch", + "bitch", + "blowjob", + "clit", + "arschloch", + "shit", + "ass", + "asshole", + "b!tch", + "b17ch", + "b1tch", + "bastard", + "bi+ch", + "boiolas", + "buceta", + "c0ck", + "cawk", + "chink", + "cipa", + "clits", + "cock", + "cum", + "cunt", + "dildo", + "dirsa", + "ejakulate", + "fatass", + "fcuk", + "fuk", + "fux0r", + "hoer", + "hore", + "jism", + "kawk", + "l3itch", + "l3i+ch", + "masturbate", + "masterbat*", + "masterbat3", + "motherfucker", + "s.o.b.", + "mofo", + "nazi", + "nigga", + "nigger", + "nutsack", + "phuck", + "pimpis", + "pusse", + "pussy", + "scrotum", + "sh!t", + "shemale", + "shi+", + "sh!+", + "slut", + "smut", + "teets", + "tits", + "boobs", + "b00bs", + "teez", + "testical", + "testicle", + "titt", + "w00se", + "jackoff", + "wank", + "whoar", + "whore", + "*damn", + "*dyke", + "*fuck*", + "*shit*", + "@$$", + "amcik", + "andskota", + "arse*", + "assrammer", + "ayir", + "bi7ch", + "bitch*", + "bollock*", + "breasts", + "butt-pirate", + "cabron", + "cazzo", + "chraa", + "chuj", + "Cock*", + "cunt*", + "d4mn", + "daygo", + "dego", + "dick*", + "dike*", + "dupa", + "dziwka", + "ejackulate", + "Ekrem*", + "Ekto", + "enculer", + "faen", + "fag*", + "fanculo", + "fanny", + "feces", + "feg", + "Felcher", + "ficken", + "fitt*", + "Flikker", + "foreskin", + "Fotze", + "Fu(*", + "fuk*", + "futkretzn", + "gook", + "guiena", + "h0r", + "h4x0r", + "hell", + "helvete", + "hoer*", + "honkey", + "Huevon", + "hui", + "injun", + "jizz", + "kanker*", + "kike", + "klootzak", + "kraut", + "knulle", + "kuk", + "kuksuger", + "Kurac", + "kurwa", + "kusi*", + "kyrpa*", + "lesbo", + "mamhoon", + "masturbat*", + "merd*", + "mibun", + "monkleigh", + "mouliewop", + "muie", + "mulkku", + "muschi", + "nazis", + "nepesaurio", + "nigger*", + "orospu", + "paska*", + "perse", + "picka", + "pierdol*", + "pillu*", + "pimmel", + "piss*", + "pizda", + "poontsee", + "poop", + "porn", + "p0rn", + "pr0n", + "preteen", + "pula", + "pule", + "puta", + "puto", + "qahbeh", + "queef*", + "rautenberg", + "schaffer", + "scheiss*", + "schlampe", + "schmuck", + "screw", + "sh!t*", + "sharmuta", + "sharmute", + "shipal", + "shiz", + "skribz", + "skurwysyn", + "sphencter", + "spic", + "spierdalaj", + "splooge", + "suka", + "b00b*", + "testicle*", + "titt*", + "twat", + "vittu", + "wank*", + "wetback*", + "wichser", + "wop*", + "yed", + "zabourah", + "paneleiro", + "panelleiro", + "bicha", + "sapatona", + "bunda", + "rabo", + "pila", + "pilinha", + "caralho", + "crl", + "fds", + "filha da mãe", + "filha da mae", + "violação", + "violação", + "queqa", + "keka", + "queca", + "foda", + "rapidinha", + "broche", + "broxe", + "minete", + "prostituta", + "chulo", + "xulo", + "lésbica", + "lesbica", + "besta", + "cabrão", + "cabra", + "cabrao", + "cabron", + "foder", + "cona", + "pariu", + "vai te lixar", + "porra", + "merda", + "bosta", + "cagar", + "viado", + "corno", + "sapatão", + "sapatao", + "vaca", + "boí", + "boi", + "cu", + "filho da mãe", + "filho da mae", + "foda", + "enrabado", + "raba", + "enrabada" + ] +} diff --git a/frontend/src/utils/isSwear.js b/frontend/src/utils/isSwear.js new file mode 100644 index 00000000..efbebecd --- /dev/null +++ b/frontend/src/utils/isSwear.js @@ -0,0 +1,17 @@ +import list from './badWords.json'; + +const badWords = list.words; + +function escapeRegExp(string) { + // eslint-disable-next-line no-useless-escape + return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); +} + +export default function isSwear(query) { + for (const badWord of badWords) { + var regex = '\\b'; + regex += escapeRegExp(badWord); + regex += '\\b'; + if (new RegExp(regex, 'i').test(query)) return true; + } +} diff --git a/frontend/src/utils/isUnique.js b/frontend/src/utils/isUnique.js new file mode 100644 index 00000000..815e3499 --- /dev/null +++ b/frontend/src/utils/isUnique.js @@ -0,0 +1,5 @@ +export default function isUnique(value, index, self) { + return self.indexOf(value) === index; +} +//Acts as a call back function for every element of an array, to see if they are unique +//Usefull for making an unique array from a redundant one diff --git a/frontend/src/utils/maxNumOccurences.js b/frontend/src/utils/maxNumOccurences.js new file mode 100644 index 00000000..911a515c --- /dev/null +++ b/frontend/src/utils/maxNumOccurences.js @@ -0,0 +1,18 @@ +export default function maxNumOccurences(array) { + if (array.length == 0) return null; + var modeMap = {}; + var maxEl = array[0], + maxCount = 1; + for (var i = 0; i < array.length; i++) { + var el = array[i]; + if (modeMap[el] == null) + modeMap[el] = 1; //Creates counter if no record has been made before + else modeMap[el]++; //Adds counter if record has been made + if (modeMap[el] > maxCount) { + //if the counter is greater the previous maxCounter, it means that this element has appeared more times than the previous element that has appeared more times + maxEl = el; + maxCount = modeMap[el]; + } + } + return maxEl; //Element in an array which has appeared more times +} From 53bfd0da275a5c3320f5d003d3c9a75b7e2a1760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B3nimo=20Mendes?= <39437433+JeronimoMendes@users.noreply.github.com> Date: Tue, 1 Nov 2022 02:52:26 +0000 Subject: [PATCH 2/4] =?UTF-8?q?Fix=20=F0=9F=94=A8=20Python=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add resolution field to question (#243) * Add resolution field * Add resolution field to serializer * Adapt endpoint * Add resolution to submission page * Remove print * Add resource system (#251) * Create `Resource` model * Create `Resource` serializer * Create POST endpoint * Add DELETE endpoint * Add permission class * Fix overall windows unresponsiveness (#253) * Added some Font responsiveness to the Landing page * Deleted redudant information * Created Responsive Functions * Altered the Navbar and LandingPage margin structure * Revisioned Margin structure of Navbar * Restructured HomePage Icons Dims * Made settings page height responsive * Finalise Settings page restructure * Finalised Login page Restructuring * Regsiter Page responsiveness restructuring * Made Final Adjustements * Readjust the max fontsize of the homebuttons * Made Font size more reposnive in Exams * Fixed the maxWidth parameter to correct unaligned arrows in ExamPage * Implement questions report system (#252) * Report model created * Report model created (maybe functional) * Noob mistakes solved * Fix some errors * Acess blocked for non-admins Non-admins can't access nor delete reports, only create them * Small changes * Removed unused import * Bump django from 3.2.13 to 3.2.14 in /backend (#256) Bumps [django](https://github.com/django/django) from 3.2.13 to 3.2.14. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.2.13...3.2.14) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump pillow from 9.1.0 to 9.1.1 in /backend (#231) Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.1.0 to 9.1.1. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.1.0...9.1.1) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump terser from 5.13.1 to 5.14.2 in /frontend (#257) Bumps [terser](https://github.com/terser/terser) from 5.13.1 to 5.14.2. - [Release notes](https://github.com/terser/terser/releases) - [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md) - [Commits](https://github.com/terser/terser/commits) --- updated-dependencies: - dependency-name: terser dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump django from 3.2.13 to 3.2.15 in /backend (#270) * Add issue templates * Bump django from 3.2.13 to 3.2.15 in /backend Bumps [django](https://github.com/django/django) from 3.2.13 to 3.2.15. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.2.13...3.2.15) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: Jerónimo Mendes <39437433+JeronimoMendes@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Add Makefile (#273) * Add issue templates * Update lint.yml * Add Makefile * Add Reports Page (#269) * Initial Enabling of ReportsPage * Changes made to the initial example * GetReports Backend initialised * Removal of console log * Default Collumn widths * Added Expanded Information per Report, made a Component for each report in the table * Serialized Foreign Key retrieving only one of the properties * Added Delete Icon * Delete Report Backend Added * Fixed Number Comparison * Added Erro Type Filtering * Added all filtering functionality * Styles the Dropdown according to new MUI5 Documentatio, simplification of style overrides * Enable hrefs to question page * Change table data display * Made only those with body show expandable icon * Separeted the ReportTable Component into 2 tider components * Created a button to alert what Question has more reports * Fixed Select in GenExamPage * Fixed Select in LeaderBoardPage * Added Corrections Co-authored-by: Jerónimo Mendes * Implement question page (#271) * Enable Question page * Add grid layout * Create new box component * Fetch Question * Add information panel * Add resolution box * Add resources panel * Create math block component * Added Basic Structure for Comment Section, css missing * Finnished Comment Section * Finished Reply Area * Make delete only a option for mods or authors * Fixing wrong Mod User expression * Responsive Fonts added with new Hooks * Made Chat and Comment Components, for factorization * Change Comment POST body * Deleted has_upvoted and has_downvoted * Pass context * Add user serializer to comment serializer * Fix typo * Connect to backend * Update on comment deletion * Update comments on comment submission * Fix typo * Fixed Horizontal Overflow due to navbar * Made TextField reset after submission * Added prop to question so that it can be unresponsive when it is not necessary * Fixed Grid Structure inconsistencies * Report Button Added * Generic Structure of the Dialog is set * Added Submission And Form * Selected Report Type CSS * Connected With Backend * Verifications and Notifications added * Fixed Verifications inconsistencies, tidier code * Small changes to responsiveness * Made the Textfield smaller and Submit button's size static * Fixed Missing Box and Missing Question ID * Enable hrefs to question page * Added Profanity control * Asked Improvements * Allow for date to be shown in each comment * Address review comments * Added Sorting capabilities to the questionArray * Transfered the Sorting functionalities to the question page * Changed the Styling of the Branch to MUIv5 * Changed Box Styling * Fixed Visual Bug in Styling of Comments * Reduced Redunduncies and made the requested changes * Reduced More styling Redundancies and added responsive Icons * Added more responsive icons * Removed Sneaky Console Logs from previous PR * Fixed ESlint bug * Missing Bracket from Commit Merge * Fix eslint * Fix code style issues with Prettier Co-authored-by: Miguel Dinis de Sousa Co-authored-by: Miguel Dinis <80652363+LordOfTheNeverThere@users.noreply.github.com> Co-authored-by: Lint Action * Fix node modules problem * Read env variables from .env file * Fix App.js and Navbar Unresponsiveness (#280) * Made Navbar minimally responsive for Mobile * Fixed Homepage Bug (Uncontrolled Width) added Box Component * Disabled Margins near root * Finding a workaround for the absence of global margins * Added Mobile Viewport restriction * making HomePage tidier and more responsive * More responsive changes * Missing Swipeble Drawer Functionality added * Made Swipeble Drawer occur in Mobile while no user is logged in * Added Detective SVG to the Drawer * Made changes to the Drawers text along with two new components to systematize our UI * Adjust AboutPage to 0 margin * Add min to heigfht of logo on the drawer * Converted About Page to MUIv5 styling * Added few new typography components and made some alterations to the previous ones * Used the new Components on the AboutPage tiding up styling also * Fixed Some Typography inconsistencies added padding * Finished AboutUs Restyling and Responsiveness * Made Navbar minimally responsive for Mobile * Fixed Homepage Bug (Uncontrolled Width) added Box Component * Disabled Margins near root * Finding a workaround for the absence of global margins * Added Mobile Viewport restriction * making HomePage tidier and more responsive * More responsive changes * Missing Swipeble Drawer Functionality added * Made Swipeble Drawer occur in Mobile while no user is logged in * Added Detective SVG to the Drawer * Made changes to the Drawers text along with two new components to systematize our UI * Adjust AboutPage to 0 margin * Add min to heigfht of logo on the drawer * Converted About Page to MUIv5 styling * Added few new typography components and made some alterations to the previous ones * Used the new Components on the AboutPage tiding up styling also * Fixed Some Typography inconsistencies added padding * Finished AboutUs Restyling and Responsiveness * Conflicts Collateral Damage solved * Reports Page No margin correction * Question Page no Margin Correction * Added the systematic typographies to question page * Correct Margin in Results Page * Correct Exam Page Margin * Renable Mobile Warning Page Functionality * Correct Margin Exam Page * Correct Leaderboard margin * Correct Margin Gen Exam Page * Correct Margins Landing Page * Fix import typo * Add default email backend * Bump oauthlib from 3.1.1 to 3.2.1 in /backend (#287) Bumps [oauthlib](https://github.com/oauthlib/oauthlib) from 3.1.1 to 3.2.1. - [Release notes](https://github.com/oauthlib/oauthlib/releases) - [Changelog](https://github.com/oauthlib/oauthlib/blob/master/CHANGELOG.rst) - [Commits](https://github.com/oauthlib/oauthlib/compare/v3.1.1...v3.2.1) --- updated-dependencies: - dependency-name: oauthlib dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Deprecate `django.conf.urls.url` and remove unused urls (#288) * Deprecation solved * Unused urls removed * Bump django from 3.2.13 to 3.2.15 in /backend (#296) Bumps [django](https://github.com/django/django) from 3.2.13 to 3.2.15. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.2.13...3.2.15) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix allowed hosts (#299) * Fix settings to allow list of allowed hosts * Update .env.example * Bump version * Fix python version Signed-off-by: dependabot[bot] Co-authored-by: Miguel Dinis <80652363+LordOfTheNeverThere@users.noreply.github.com> Co-authored-by: afonsofsdomingues <92863313+afonsofsdomingues@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Miguel Dinis de Sousa Co-authored-by: Lint Action Co-authored-by: pearsettings44 --- backend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index dffd512e..44ff41cd 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3 +FROM python:3.10 ENV PYTHONDONTWRITEBYTECODE=1 From 00387dc894316321f917486f2c3a0d53f6401fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jer=C3=B3nimo=20Mendes?= <39437433+JeronimoMendes@users.noreply.github.com> Date: Mon, 21 Nov 2022 00:41:34 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Hotfix=20=F0=9F=94=A7=20Fix=20Question=20de?= =?UTF-8?q?lete=20vulnerability=20=20(#306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix vulnerability --- backend/api/views.py | 898 +++++++++++++++++++++++-------------------- 1 file changed, 488 insertions(+), 410 deletions(-) diff --git a/backend/api/views.py b/backend/api/views.py index 05940bac..610d1e35 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -8,7 +8,12 @@ from .serializer import * from rest_framework.response import Response import random -from rest_framework.parsers import FileUploadParser, MultiPartParser, FormParser, JSONParser +from rest_framework.parsers import ( + FileUploadParser, + MultiPartParser, + FormParser, + JSONParser, +) from django.shortcuts import get_object_or_404, get_list_or_404 from rest_framework.permissions import IsAuthenticated, AllowAny, IsAdminUser import datetime @@ -22,594 +27,667 @@ MAX_UNANSWERED_QUESTIONS_RECOMMENDED = 7 # Create your views here. + class QuestionsListView(generics.ListAPIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] - queryset = Question.objects.all() - serializer_class = QuestionSerializer + queryset = Question.objects.all() + serializer_class = QuestionSerializer class QuestionView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request, id): - question = get_object_or_404(Question, id=id) + permission_classes = [IsAuthenticated] - return Response(QuestionSerializer(question, context=request).data, status=status.HTTP_200_OK) + def get(self, request, id): + question = get_object_or_404(Question, id=id) + return Response( + QuestionSerializer(question, context=request).data, + status=status.HTTP_200_OK, + ) - # Validates a question - def put(self, request, id): - if not(request.user.is_superuser): - return Response(status=status.HTTP_403_FORBIDDEN) + # Validates a question + def put(self, request, id): + if not (request.user.is_superuser): + return Response(status=status.HTTP_403_FORBIDDEN) - question = get_object_or_404(Question, id=id) - question.accepted = True - question.save() + question = get_object_or_404(Question, id=id) + question.accepted = True + question.save() - return Response(status=status.HTTP_200_OK) + return Response(status=status.HTTP_200_OK) - - def delete(self, request, id): - question = get_object_or_404(Question, id=id) + def delete(self, request, id): + question = get_object_or_404(Question, id=id) - if (request.user != question.author) and not (request.user.is_superuser): - return Response(status=status.HTTP_403_FORBIDDEN) + if (request.user != question.author) or not (request.user.is_superuser): + return Response(status=status.HTTP_403_FORBIDDEN) - question.delete() + question.delete() - return Response(status=status.HTTP_200_OK) + return Response(status=status.HTTP_200_OK) + # Creates new question request, which will have to be validated + def post(self, request): + question = CreateQuestionSerializer(data=request.data) + if question.is_valid(): + newQuestion = Question.objects.create() - # Creates new question request, which will have to be validated - def post(self, request): - question = CreateQuestionSerializer(data=request.data) - if question.is_valid(): - newQuestion = Question.objects.create() - - for answer in question.data.get("answers"): - newAnswer = Answer.objects.create(text=answer["text"], correct=answer["correct"], question=newQuestion) - newAnswer.save() + for answer in question.data.get("answers"): + newAnswer = Answer.objects.create( + text=answer["text"], correct=answer["correct"], question=newQuestion + ) + newAnswer.save() - if question.data.get("source"): - newQuestion.source = question.data.get("source") + if question.data.get("source"): + newQuestion.source = question.data.get("source") - newQuestion.text = question.data.get("text") - newQuestion.resolution = question.data.get("resolution") - newQuestion.subject = question.data.get("subject") - newQuestion.subsubject = question.data.get("subsubject") - newQuestion.year = question.data.get("year") - newQuestion.author = request.user + newQuestion.text = question.data.get("text") + newQuestion.resolution = question.data.get("resolution") + newQuestion.subject = question.data.get("subject") + newQuestion.subsubject = question.data.get("subsubject") + newQuestion.year = question.data.get("year") + newQuestion.author = request.user - newQuestion.save() + newQuestion.save() - return Response(QuestionSerializer(newQuestion).data, status=status.HTTP_201_CREATED) - else: - return Response({"Bad Request": "Bad data"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + QuestionSerializer(newQuestion).data, status=status.HTTP_201_CREATED + ) + else: + return Response( + {"Bad Request": "Bad data"}, status=status.HTTP_400_BAD_REQUEST + ) class AddImageToQuestion(APIView): - parser_classes = [MultiPartParser] - permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser] + permission_classes = [IsAuthenticated] - def post(self, request, *args, **kwargs): - question = Question.objects.get(id=kwargs.get("id")) + def post(self, request, *args, **kwargs): + question = Question.objects.get(id=kwargs.get("id")) - if not self.request.user.is_authenticated: - return Response({"Bad Request": "User not logged in..."}, status=status.HTTP_400_BAD_REQUEST) + if not self.request.user.is_authenticated: + return Response( + {"Bad Request": "User not logged in..."}, + status=status.HTTP_400_BAD_REQUEST, + ) - if question.author != self.request.user: - return Response(status=status.HTTP_403_FORBIDDEN) + if question.author != self.request.user: + return Response(status=status.HTTP_403_FORBIDDEN) - image = request.data["file"] + image = request.data["file"] - question.image = image - question.save() + question.image = image + question.save() - return Response(status=status.HTTP_202_ACCEPTED) + return Response(status=status.HTTP_202_ACCEPTED) class SubmittedQuestions(generics.ListAPIView): - permission_classes = [IsAuthenticated] - queryset = Question.objects.filter(accepted=False) - serializer_class = QuestionSerializer + permission_classes = [IsAuthenticated] + queryset = Question.objects.filter(accepted=False) + serializer_class = QuestionSerializer class CommentView(APIView): - permission_classes = [IsAuthenticated] - serializer_class = CommentSerializer - - def get(self, request, id): - if not self.request.user.is_authenticated: - return Response({"Bad Request": "User not logged in..."}, status=status.HTTP_400_BAD_REQUEST) - - comment = get_object_or_404(Comment, id=id) - - return Response(self.serializer_class(comment, context=request).data, status=status.HTTP_200_OK) - - def post(self, request): - if not self.request.user.is_authenticated: - return Response({"Bad Request": "User not logged in..."}, status=status.HTTP_400_BAD_REQUEST) - - serializer = CommentCreateSerializer(data=request.data) - if serializer.is_valid(): - content = serializer.data.get("content") - question = Question.objects.get(id=serializer.data.get("question")) - comment = Comment(author=self.request.user, question=question, content=content) - comment.save() + permission_classes = [IsAuthenticated] + serializer_class = CommentSerializer + + def get(self, request, id): + if not self.request.user.is_authenticated: + return Response( + {"Bad Request": "User not logged in..."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + comment = get_object_or_404(Comment, id=id) + + return Response( + self.serializer_class(comment, context=request).data, + status=status.HTTP_200_OK, + ) + + def post(self, request): + if not self.request.user.is_authenticated: + return Response( + {"Bad Request": "User not logged in..."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = CommentCreateSerializer(data=request.data) + if serializer.is_valid(): + content = serializer.data.get("content") + question = Question.objects.get(id=serializer.data.get("question")) + comment = Comment( + author=self.request.user, question=question, content=content + ) + comment.save() + + return Response( + CommentSerializer(comment, context=request).data, + status=status.HTTP_201_CREATED, + ) + + return Response( + {"Bad Request": "Invalid data..."}, status=status.HTTP_400_BAD_REQUEST + ) + + def delete(self, request, id): + comment = get_object_or_404(Comment, id=id) + if self.request.user == comment.author: + comment.delete() + + return Response({"Comment deleted!"}, status=status.HTTP_200_OK) + + return Response( + {"Can't delete other user's comments"}, status=status.HTTP_401_UNAUTHORIZED + ) - return Response(CommentSerializer(comment, context=request).data, status=status.HTTP_201_CREATED) - - return Response({"Bad Request": "Invalid data..."}, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, id): - comment = get_object_or_404(Comment, id=id) - if self.request.user == comment.author: - comment.delete() - - return Response({"Comment deleted!"}, status=status.HTTP_200_OK) +class UpvoteCommentView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = CommentSerializer - return Response({"Can't delete other user's comments"}, status=status.HTTP_401_UNAUTHORIZED) + # Upvotes a Comment + def post(self, request, id): + comment = Comment.objects.get(id=id) + if request.user in comment.upvoters.all(): + return Response( + {"User already upvoted this comment"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if request.user in comment.downvoters.all(): + comment.votes += 2 + comment.downvoters.remove(request.user) + else: + comment.votes += 1 -class UpvoteCommentView(APIView): - permission_classes = [IsAuthenticated] - serializer_class = CommentSerializer + comment.upvoters.add(request.user) + comment.save() - # Upvotes a Comment - def post(self, request, id): - comment = Comment.objects.get(id=id) - if request.user in comment.upvoters.all(): - return Response({"User already upvoted this comment"}, status=status.HTTP_400_BAD_REQUEST) + return Response("Comment upvoted!", status=status.HTTP_200_OK) - if request.user in comment.downvoters.all(): - comment.votes += 2 - comment.downvoters.remove(request.user) - else: - comment.votes += 1 - - comment.upvoters.add(request.user) - comment.save() + # Removes an upvote from a Comment + def delete(self, request, id): + comment = Comment.objects.get(id=id) + if request.user not in comment.upvoters.all(): + return Response( + {"User hasn't upvoted this comment"}, status=status.HTTP_400_BAD_REQUEST + ) - - return Response("Comment upvoted!", status=status.HTTP_200_OK) + comment.votes -= 1 - # Removes an upvote from a Comment - def delete(self, request, id): - comment = Comment.objects.get(id=id) - if request.user not in comment.upvoters.all(): - return Response({"User hasn't upvoted this comment"}, status=status.HTTP_400_BAD_REQUEST) + comment.upvoters.remove(request.user) + comment.save() - comment.votes -= 1 - - comment.upvoters.remove(request.user) - comment.save() + return Response("Removed upvote!", status=status.HTTP_200_OK) - return Response("Removed upvote!", status=status.HTTP_200_OK) +class DownvoteCommentView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = CommentSerializer + # Downvotes a Comment + def post(self, request, id): + comment = Comment.objects.get(id=id) + if request.user in comment.downvoters.all(): + return Response( + {"User already downvoted this comment"}, + status=status.HTTP_400_BAD_REQUEST, + ) -class DownvoteCommentView(APIView): - permission_classes = [IsAuthenticated] - serializer_class = CommentSerializer + if request.user in comment.upvoters.all(): + comment.votes -= 2 + comment.upvoters.remove(request.user) + else: + comment.votes -= 1 - # Downvotes a Comment - def post(self, request, id): - comment = Comment.objects.get(id=id) - if request.user in comment.downvoters.all(): - return Response({"User already downvoted this comment"}, status=status.HTTP_400_BAD_REQUEST) + comment.downvoters.add(request.user) + comment.save() - if request.user in comment.upvoters.all(): - comment.votes -= 2 - comment.upvoters.remove(request.user) - else: - comment.votes -= 1 - - comment.downvoters.add(request.user) - comment.save() + return Response("Comment downvoted!", status=status.HTTP_200_OK) - return Response("Comment downvoted!", status=status.HTTP_200_OK) + # Removes a downvote from a Comment + def delete(self, request, id): + comment = Comment.objects.get(id=id) + if request.user not in comment.downvoters.all(): + return Response( + {"User hasn't downvoted this comment"}, + status=status.HTTP_400_BAD_REQUEST, + ) - # Removes a downvote from a Comment - def delete(self, request, id): - comment = Comment.objects.get(id=id) - if request.user not in comment.downvoters.all(): - return Response({"User hasn't downvoted this comment"}, status=status.HTTP_400_BAD_REQUEST) + comment.votes += 1 - comment.votes += 1 - - comment.downvoters.remove(request.user) - comment.save() + comment.downvoters.remove(request.user) + comment.save() - return Response("Removed downvote", status=status.HTTP_200_OK) + return Response("Removed downvote", status=status.HTTP_200_OK) class ExamView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] - def get(self, request, id): - exam = get_object_or_404(Exam, id=id) + def get(self, request, id): + exam = get_object_or_404(Exam, id=id) - return Response(ExamSerializer(exam).data, status=status.HTTP_200_OK) + return Response(ExamSerializer(exam).data, status=status.HTTP_200_OK) + def post(self, request): - def post(self, request): + serializer = CreateExamSerializer(data=request.data) + if serializer.is_valid(): + subject = serializer.data.get("subject") + year = serializer.data.get("year") + subSubjects = serializer.data.get("subSubjects") - serializer = CreateExamSerializer(data=request.data) - if serializer.is_valid(): - subject = serializer.data.get("subject") - year = serializer.data.get("year") - subSubjects = serializer.data.get("subSubjects") + questionsQuery = [] - questionsQuery = [] + if subSubjects: # If there are any subsubjects specified + if year: + questionsQuery += list( + Question.objects.filter( + year__in=year, subsubject__in=subSubjects, accepted=True + ) + ) + else: + questionsQuery += list( + Question.objects.filter( + subsubject__in=subSubjects, accepted=True + ) + ) + else: # User wants a random subsubjects exam + if year: + questionsQuery += list( + Question.objects.filter(year__in=year, accepted=True) + ) + else: + questionsQuery += list(Question.objects.filter(accepted=True)) - if (subSubjects): # If there are any subsubjects specified - if year: - questionsQuery += list(Question.objects.filter(year__in=year, subsubject__in=subSubjects, accepted=True)) - else: - questionsQuery += list(Question.objects.filter(subsubject__in=subSubjects, accepted=True)) - else: # User wants a random subsubjects exam - if year: - questionsQuery += list(Question.objects.filter(year__in=year, accepted=True)) - else: - questionsQuery += list(Question.objects.filter(accepted=True)) + # Selects randomly a set of final questions for the exam + if len(questionsQuery) < QUESTION_PER_EXAM: + return Response( + {"error": "Not enough questions"}, + status=status.HTTP_400_BAD_REQUEST, + ) - # Selects randomly a set of final questions for the exam - if len(questionsQuery) < QUESTION_PER_EXAM: - return Response({"error": "Not enough questions" }, status=status.HTTP_400_BAD_REQUEST) + questions = random.sample(list(questionsQuery), QUESTION_PER_EXAM) + exam = Exam.objects.create() - questions = random.sample(list(questionsQuery), QUESTION_PER_EXAM) - - exam = Exam.objects.create() + for question in questions: + exam.questions.add(question) - for question in questions: exam.questions.add(question) + exam.save() - exam.save() + return Response(ExamSerializer(exam).data, status=status.HTTP_201_CREATED) - return Response(ExamSerializer(exam).data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def put(self, request, id): + exam = get_object_or_404(Exam, id=id) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + if exam.correct.count() or exam.failed.count(): + return Response( + {"Bad Request": "Exam already submitted"}, + status=status.HTTP_400_BAD_REQUEST, + ) + profileSubject = request.user.profile.subjects.get(subject="Matemática") + profileSubject.examCounter += 1 + for question, answer in request.data.items(): - def put(self, request, id): - exam = get_object_or_404(Exam, id=id) + questionQuery = Question.objects.get(id=int(question)) + if int(answer) != 0: + answerQuery = Answer.objects.get(id=int(answer)) - if exam.correct.count() or exam.failed.count(): - return Response({"Bad Request": "Exam already submitted"}, status=status.HTTP_400_BAD_REQUEST) + if answerQuery.correct: + profileSubject.addCorrectAnswer(questionQuery) + exam.correct.add(questionQuery) - profileSubject = request.user.profile.subjects.get(subject="Matemática") - profileSubject.examCounter += 1 - for question, answer in request.data.items(): + request.user.profile.xp.xp += XP_PER_CORRECT_ANSWER + exam.score += 20 - questionQuery = Question.objects.get(id=int(question)) - if int(answer) != 0: - answerQuery = Answer.objects.get(id=int(answer)) + else: + profileSubject.addWrongAnswer(questionQuery) + exam.failed.add(questionQuery) - if answerQuery.correct: - profileSubject.addCorrectAnswer(questionQuery) - exam.correct.add(questionQuery) + else: + profileSubject.addWrongAnswer(questionQuery) + exam.failed.add(questionQuery) - request.user.profile.xp.xp += XP_PER_CORRECT_ANSWER - exam.score += 20 + request.user.profile.xp.xp += XP_PER_EXAM + request.user.profile.xp.save() + exam.save() + profileSubject.save() - else: - profileSubject.addWrongAnswer(questionQuery) - exam.failed.add(questionQuery) + serializer = ExamSerializer(exam) - else: - profileSubject.addWrongAnswer(questionQuery) - exam.failed.add(questionQuery) + return Response(serializer.data, status=status.HTTP_200_OK) - request.user.profile.xp.xp += XP_PER_EXAM - request.user.profile.xp.save() - exam.save() - profileSubject.save() - serializer = ExamSerializer(exam) +class RecommendedExamView(APIView): + def post(self, request): + serializer = CreateRecommendedExamSerializer(data=request.data) + serializer.is_valid(raise_exception=True) - return Response(serializer.data, status=status.HTTP_200_OK) + subject = serializer.data.get("subject") + print(subject) -class RecommendedExamView(APIView): - def post(self, request): - serializer = CreateRecommendedExamSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + user_subject = request.user.profile.subjects.get(subject=subject) - subject = serializer.data.get("subject") - print(subject) + # Fetch questions the user hasn't answered + questions_wrong = [i.answer for i in user_subject.wrongAnswers.all()] + questions_correct = [i.answer for i in user_subject.correctAnswers.all()] + questions_wrong_id = [i.id for i in questions_wrong] + questions_correct_id = [i.id for i in questions_correct] + questions_unanswered = list( + Question.objects.exclude(id__in=questions_correct_id) + .exclude(id__in=questions_wrong_id) + .filter(accepted=True) + ) - user_subject = request.user.profile.subjects.get(subject=subject) + # Only insert a certain amount of unasnwered questions in the exam + if len(questions_unanswered) > MAX_UNANSWERED_QUESTIONS_RECOMMENDED: + questions_unanswered_selected = random.sample( + questions_unanswered, MAX_UNANSWERED_QUESTIONS_RECOMMENDED + ) + else: + questions_unanswered_selected = questions_unanswered - # Fetch questions the user hasn't answered - questions_wrong = [i.answer for i in user_subject.wrongAnswers.all()] - questions_correct = [i.answer for i in user_subject.correctAnswers.all()] - questions_wrong_id = [i.id for i in questions_wrong] - questions_correct_id = [i.id for i in questions_correct] - questions_unanswered = list(Question.objects.exclude(id__in=questions_correct_id).exclude(id__in=questions_wrong_id).filter(accepted=True)) + questions = questions_unanswered_selected - # Only insert a certain amount of unasnwered questions in the exam - if len(questions_unanswered) > MAX_UNANSWERED_QUESTIONS_RECOMMENDED: - questions_unanswered_selected = random.sample(questions_unanswered, MAX_UNANSWERED_QUESTIONS_RECOMMENDED) - else: questions_unanswered_selected = questions_unanswered + # Check if there are enough wrong answers to fill the exam, if not insert correct answers + space_left = QUESTION_PER_EXAM - len(questions) + if len(questions_wrong) >= space_left: + questions_wrong_selected = random.sample(questions_wrong, space_left) - questions = questions_unanswered_selected + questions += questions_wrong_selected + # Return a "perfect" exam - # Check if there are enough wrong answers to fill the exam, if not insert correct answers - space_left = QUESTION_PER_EXAM - len(questions) - if len(questions_wrong) >= space_left: - questions_wrong_selected = random.sample(questions_wrong, space_left) + else: + questions += questions_wrong - questions += questions_wrong_selected - # Return a "perfect" exam + # There are not enough wrong and unanswered questions so when need to get some right answers + space_left = QUESTION_PER_EXAM - len(questions) - else: - questions += questions_wrong - - # There are not enough wrong and unanswered questions so when need to get some right answers - space_left = QUESTION_PER_EXAM - len(questions) + if len(questions_correct) < space_left: + questions_right_selected = questions_correct + questions += random.sample( + set(questions_unanswered) - set(questions_unanswered_selected), + space_left - len(questions_correct), + ) + else: + questions_right_selected = random.sample(questions_correct, space_left) - if len(questions_correct) < space_left: - questions_right_selected = questions_correct - questions += random.sample(set(questions_unanswered) - set(questions_unanswered_selected), space_left - len(questions_correct)) - else: - questions_right_selected = random.sample(questions_correct, space_left) + questions += questions_right_selected - questions += questions_right_selected + if len(questions) < 10: + return Response( + {"error": "Could not create a recommended exam"}, + status=status.HTTP_400_BAD_REQUEST, + ) - if len(questions) < 10: - return Response({"error": "Could not create a recommended exam" }, status=status.HTTP_400_BAD_REQUEST) + exam = Exam.objects.create() - exam = Exam.objects.create() + for question in questions: + exam.questions.add(question) - for question in questions: exam.questions.add(question) + # Return a "perfect" exam + return Response(ExamSerializer(exam).data, status=status.HTTP_201_CREATED) - # Return a "perfect" exam - return Response(ExamSerializer(exam).data, status=status.HTTP_201_CREATED) class AchievementsListView(generics.ListAPIView): - permission_classes = [IsAuthenticated] - queryset = Achievement.objects.all() - serializer_class = AchievementSerializer + permission_classes = [IsAuthenticated] + queryset = Achievement.objects.all() + serializer_class = AchievementSerializer class ProfileView(generics.RetrieveAPIView): - permission_classes = [IsAuthenticated] - serializer_class = ProfileSerializer - lookup_field = "id" - queryset = Profile.objects.all() + permission_classes = [IsAuthenticated] + serializer_class = ProfileSerializer + lookup_field = "id" + queryset = Profile.objects.all() class CurrentUserView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] - def get(self, request): - serializer = UserSerializer(request.user) - return Response(serializer.data) - + def get(self, request): + serializer = UserSerializer(request.user) + return Response(serializer.data) class XPEventsAPI(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] - def get(self, request, id): - user = get_object_or_404(User, id=id) - events = XPEvent.objects.filter(user=user) - serializer = XPEventSerializer(events, many=True) - return Response(serializer.data) + def get(self, request, id): + user = get_object_or_404(User, id=id) + events = XPEvent.objects.filter(user=user) + serializer = XPEventSerializer(events, many=True) + return Response(serializer.data) class SubjectAPI(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] - class Subject: - def __init__(self, subject, questions): - self.subject = subject - self.questions = questions - - def get(self, request, subject): - questions = get_list_or_404(Question, subject=subject) - sub = self.Subject(subject=subject, questions=questions) + class Subject: + def __init__(self, subject, questions): + self.subject = subject + self.questions = questions - return Response(SubjectInfoSerializer(sub).data) + def get(self, request, subject): + questions = get_list_or_404(Question, subject=subject) + sub = self.Subject(subject=subject, questions=questions) + return Response(SubjectInfoSerializer(sub).data) -class Leaderboard(APIView): - class XPProfile: - def __init__(self, id, xp): - self.id = id - self.xp = xp - def get(self, request, time, page): - def checkForUser(list, userID): - for i in list: - if i.id == userID: return True +class Leaderboard(APIView): + class XPProfile: + def __init__(self, id, xp): + self.id = id + self.xp = xp - return False + def get(self, request, time, page): + def checkForUser(list, userID): + for i in list: + if i.id == userID: + return True - def getXPProfile(list, userID): - for i in list: - if i.id == userID: return i + return False - start_position = (page - 1) * LEADERBOARD_PAGE_SIZE - end_position = page * LEADERBOARD_PAGE_SIZE + def getXPProfile(list, userID): + for i in list: + if i.id == userID: + return i - # Alltime leaderboard - if time == "alltime": - users = Profile.objects.order_by("-xp__total_xp") + start_position = (page - 1) * LEADERBOARD_PAGE_SIZE + end_position = page * LEADERBOARD_PAGE_SIZE - # Creates an XPProfile object for each user - formated_users = [self.XPProfile(i.id, i.xp.total_xp) for i in users[start_position:end_position]] + # Alltime leaderboard + if time == "alltime": + users = Profile.objects.order_by("-xp__total_xp") - leaderboard = { - "users": formated_users, - "length": Profile.objects.count() - } + # Creates an XPProfile object for each user + formated_users = [ + self.XPProfile(i.id, i.xp.total_xp) + for i in users[start_position:end_position] + ] - return Response(LeaderboardSerializer(leaderboard).data) + leaderboard = {"users": formated_users, "length": Profile.objects.count()} - # Leaderboards with time span - elif time == "month": - date = datetime.date.today() - datetime.timedelta(days=30) - elif time == "day": - date = datetime.date.today() - datetime.timedelta(days=1) - else: - return Response({"Bad Request": "Invalid date"}, status=status.HTTP_400_BAD_REQUEST) + return Response(LeaderboardSerializer(leaderboard).data) - events = XPEvent.objects.filter(date__gte=date) + # Leaderboards with time span + elif time == "month": + date = datetime.date.today() - datetime.timedelta(days=30) + elif time == "day": + date = datetime.date.today() - datetime.timedelta(days=1) + else: + return Response( + {"Bad Request": "Invalid date"}, status=status.HTTP_400_BAD_REQUEST + ) - users = [] - for n, i in enumerate(events): - repeated = False - for e in events[:n]: - if i.user == e.user: - repeated = True + events = XPEvent.objects.filter(date__gte=date) - if not repeated: users.append(i.user) + users = [] + for n, i in enumerate(events): + repeated = False + for e in events[:n]: + if i.user == e.user: + repeated = True + if not repeated: + users.append(i.user) - usersXP = [] - for user in users[start_position:end_position + 1]: - for event in events: - if user == event.user: - if not checkForUser(usersXP, user.profile.id): - usersXP.append(self.XPProfile(user.profile.id, event.amount)) - else: - getXPProfile(usersXP, user.profile.id).xp += event.amount + usersXP = [] + for user in users[start_position : end_position + 1]: + for event in events: + if user == event.user: + if not checkForUser(usersXP, user.profile.id): + usersXP.append(self.XPProfile(user.profile.id, event.amount)) + else: + getXPProfile(usersXP, user.profile.id).xp += event.amount - usersXP.sort(key=lambda x: x.xp, reverse=True) + usersXP.sort(key=lambda x: x.xp, reverse=True) - leaderboard = { - "users": usersXP, - "length": len(users) - } + leaderboard = {"users": usersXP, "length": len(users)} - return Response(LeaderboardSerializer(leaderboard).data) + return Response(LeaderboardSerializer(leaderboard).data) class Follow(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] - def get(self, request, id): - user_profile = request.user.profile + def get(self, request, id): + user_profile = request.user.profile - user_to_follow = User.objects.get(id=id) + user_to_follow = User.objects.get(id=id) - if request.user == user_to_follow: - return Response(status=status.HTTP_400_BAD_REQUEST) + if request.user == user_to_follow: + return Response(status=status.HTTP_400_BAD_REQUEST) - user_profile.addToFollowing(user_to_follow) + user_profile.addToFollowing(user_to_follow) - return Response(status=status.HTTP_200_OK) - + return Response(status=status.HTTP_200_OK) - def delete(self, request, id): - user_profile = request.user.profile + def delete(self, request, id): + user_profile = request.user.profile - user_to_remove_follow = User.objects.get(id=id) + user_to_remove_follow = User.objects.get(id=id) - if user_to_remove_follow not in user_profile.follows.all(): - return Response(status=status.HTTP_400_BAD_REQUEST) + if user_to_remove_follow not in user_profile.follows.all(): + return Response(status=status.HTTP_400_BAD_REQUEST) - user_profile.removeFromFollowing(user_to_remove_follow) + user_profile.removeFromFollowing(user_to_remove_follow) + + return Response(status=status.HTTP_200_OK) - return Response(status=status.HTTP_200_OK) class VerifyEmailView(APIView): - permission_classes = (AllowAny,) - allowed_methods = ('GET', 'OPTIONS', 'HEAD') + permission_classes = (AllowAny,) + allowed_methods = ("GET", "OPTIONS", "HEAD") + + def get(self, request, code, username): + user = User.objects.get(username=username) - def get(self, request, code, username): - user = User.objects.get(username=username) + if user.profile.email_confirmation_code == code: + email = EmailAddress.objects.get(user=user) + email.verified = True + email.save() - if (user.profile.email_confirmation_code == code): - email = EmailAddress.objects.get(user=user) - email.verified = True - email.save() + return Response({"detail": "ok"}, status=status.HTTP_200_OK) - return Response({'detail': 'ok'}, status=status.HTTP_200_OK) - - return Response({'detail': 'wrong code'}, status=status.HTTP_400_BAD_REQUEST) + return Response({"detail": "wrong code"}, status=status.HTTP_400_BAD_REQUEST) class Users(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] + + def get(self, request): + number_of_users = User.objects.count() - def get(self, request): - number_of_users = User.objects.count() + return Response(number_of_users, status=status.HTTP_200_OK) - return Response(number_of_users, status=status.HTTP_200_OK) class DeleteAccount(APIView): - permission_classes = [IsAuthenticated] - serializer_class = DeleteAccountSerializer + permission_classes = [IsAuthenticated] + serializer_class = DeleteAccountSerializer - def delete(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data, context={'request': request}) - serializer.is_valid(raise_exception=True) + def delete(self, request, *args, **kwargs): + serializer = self.serializer_class( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + + return Response( + {"detail": ("Account has been deleted")}, status=status.HTTP_200_OK + ) - return Response( - {"detail": ("Account has been deleted")}, - status=status.HTTP_200_OK - ) class ResourceView(APIView): - permission_classes = [IsAdminUser] - - def post(self, request, id): - serializer = ResourceSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + permission_classes = [IsAdminUser] + + def post(self, request, id): + serializer = ResourceSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + resource = Resource.objects.create( + description=serializer.data.get("description"), + url=serializer.data.get("url"), + type=serializer.data.get("type"), + question=get_object_or_404(Question, id=id), + ) - resource = Resource.objects.create( - description=serializer.data.get("description"), - url=serializer.data.get("url"), - type=serializer.data.get("type"), - question=get_object_or_404(Question, id=id) - ) + return Response( + ResourceSerializer(resource).data, status=status.HTTP_201_CREATED + ) - return Response(ResourceSerializer(resource).data, status=status.HTTP_201_CREATED) + def delete(self, request, id): + resource = get_object_or_404(Resource, id=id) + resource.delete() - def delete(self, request, id): - resource = get_object_or_404(Resource, id=id) - resource.delete() + return Response(status=status.HTTP_200_OK) - return Response(status=status.HTTP_200_OK) class ReportListView(generics.ListAPIView): - permission_classes = [IsAdminUser] + permission_classes = [IsAdminUser] + + queryset = Report.objects.all() + serializer_class = ReportSerializer - queryset = Report.objects.all() - serializer_class = ReportSerializer class ReportView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request): - serializer = ReportSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ReportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + report = Report.objects.create( + question=get_object_or_404(Question, id=serializer.data.get("question")), + author=request.user, + type=serializer.data.get("type"), + body=serializer.data.get("body"), + ) - report = Report.objects.create( - question = get_object_or_404(Question, id=serializer.data.get("question")), - author = request.user, - type = serializer.data.get("type"), - body = serializer.data.get("body") - ) + return Response(ReportSerializer(report).data, status=status.HTTP_201_CREATED) - return Response(ReportSerializer(report).data, status=status.HTTP_201_CREATED) + def delete(self, request, id): + if not (request.user.is_staff): + return Response(status=status.HTTP_403_FORBIDDEN) - def delete(self, request, id): - if not(request.user.is_staff): - return Response(status=status.HTTP_403_FORBIDDEN) + report = get_object_or_404(Report, id=id) + report.delete() - report = get_object_or_404(Report, id=id) - report.delete() + return Response(status=status.HTTP_200_OK) - return Response(status=status.HTTP_200_OK) - - def get(self, request, id): - if not(request.user.is_staff): - return Response(status=status.HTTP_403_FORBIDDEN) + def get(self, request, id): + if not (request.user.is_staff): + return Response(status=status.HTTP_403_FORBIDDEN) - report = get_object_or_404(Report, id=id) + report = get_object_or_404(Report, id=id) - return Response(ReportSerializer(report).data, status=status.HTTP_200_OK) \ No newline at end of file + return Response(ReportSerializer(report).data, status=status.HTTP_200_OK) From f0553c040b8980db1b9e6bf46f8473350d56cfed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Nov 2022 11:31:25 +0000 Subject: [PATCH 4/4] Bump pillow from 9.1.1 to 9.3.0 in /backend Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.1.1 to 9.3.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.1.1...9.3.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index f8af8365..55033824 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,7 +25,7 @@ idna==3.2 kombu==5.2.4 oauthlib==3.2.1 packaging==21.3 -Pillow==9.1.1 +Pillow==9.3.0 prompt-toolkit==3.0.29 psycopg2==2.9.1 pycparser==2.20