├── docs ├── .keep ├── img │ ├── .keep │ └── demo_gif.gif ├── cas.md └── api-soap-siding.md ├── backend ├── readme.md ├── app │ ├── __init__.py │ ├── plan │ │ ├── __init__.py │ │ ├── validation │ │ │ ├── courses │ │ │ │ └── __init__.py │ │ │ ├── curriculum │ │ │ │ └── __init__.py │ │ │ ├── __init__.py │ │ │ └── validate.py │ │ ├── plan.py │ │ └── course.py │ ├── user │ │ ├── __init__.py │ │ └── info.py │ ├── sync │ │ ├── siding │ │ │ └── __init__.py │ │ └── curriculums │ │ │ ├── __init__.py │ │ │ ├── scrape │ │ │ ├── __init__.py │ │ │ ├── major.py │ │ │ └── common.py │ │ │ └── minor.py │ ├── routes │ │ ├── __init__.py │ │ ├── offer.py │ │ └── user.py │ ├── database.py │ ├── redis.py │ ├── logger.py │ └── limiting.py ├── scripts │ ├── __init__.py │ ├── prestart.sh │ ├── dump_curriculums.py │ └── startup.py ├── tests │ ├── __init__.py │ ├── test_dnf.py │ ├── test_client.py │ └── test_tree.py ├── typings │ ├── ortools │ │ ├── __init__.pyi │ │ ├── sat │ │ │ ├── __init__.pyi │ │ │ └── python │ │ │ │ └── __init__.pyi │ │ └── linear_solver │ │ │ └── __init__.pyi │ └── cas │ │ ├── anyio │ │ ├── _core │ │ │ └── __init__.pyi │ │ └── abc │ │ │ └── __init__.pyi │ │ ├── multipart │ │ └── __init__.pyi │ │ ├── fastapi │ │ ├── openapi │ │ │ └── __init__.pyi │ │ ├── security │ │ │ └── __init__.pyi │ │ ├── middleware │ │ │ └── __init__.pyi │ │ └── __init__.pyi │ │ ├── idna │ │ └── __init__.pyi │ │ ├── prisma │ │ ├── binaries │ │ │ └── __init__.pyi │ │ ├── __init__.pyi │ │ └── engine │ │ │ └── __init__.pyi │ │ ├── click │ │ └── __init__.pyi │ │ ├── lxml │ │ └── __init__.pyi │ │ ├── httpcore │ │ ├── __init__.pyi │ │ └── backends │ │ │ └── __init__.pyi │ │ ├── certifi │ │ └── __init__.pyi │ │ ├── orjson │ │ └── __init__.pyi │ │ ├── dns │ │ └── __init__.pyi │ │ ├── starlette │ │ ├── __init__.pyi │ │ └── middleware │ │ │ └── __init__.pyi │ │ ├── tomlkit │ │ └── __init__.pyi │ │ ├── dotenv │ │ └── __init__.pyi │ │ ├── markupsafe │ │ └── __init__.pyi │ │ ├── sniffio │ │ └── __init__.pyi │ │ ├── h11 │ │ └── __init__.pyi │ │ └── httpx │ │ ├── _transports │ │ └── __init__.pyi │ │ └── __init__.pyi ├── prisma │ └── migrations │ │ ├── 20230122024522_add_is_favorite_to_plan │ │ └── migration.sql │ │ ├── migration_lock.toml │ │ ├── 20221211040818_add_course_rules │ │ └── migration.sql │ │ ├── 20230716225844_reset_curriculum │ │ └── migration.sql │ │ ├── 20230516222704_clear_curriculum │ │ └── migration.sql │ │ ├── 20230522154527_cached_course_info │ │ └── migration.sql │ │ ├── 20230108052921_drop_posts_table │ │ └── migration.sql │ │ ├── 20230721211317_normalize_ruts │ │ └── migration.sql │ │ ├── 20221214170908_drop_course_rules_cache │ │ └── migration.sql │ │ ├── 20230627211411_add_inv_equivs │ │ └── migration.sql │ │ ├── 20230110033455_remove_example_curriculum │ │ └── migration.sql │ │ ├── 20230516163839_remove_planrecommendation │ │ └── migration.sql │ │ ├── 20221219025329_add_curriculums │ │ └── migration.sql │ │ ├── 20230114200140_add_equivalence_data │ │ └── migration.sql │ │ ├── 20230110030021_add_cyear_to_major_minor │ │ └── migration.sql │ │ ├── 20230523160411_preserve_equiv_order │ │ └── migration.sql │ │ ├── 20230602043939_remove_tav │ │ └── migration.sql │ │ ├── 20231004172806_update_plan_cyear │ │ └── migration.sql │ │ ├── 20230529093144_add_access_level │ │ └── migration.sql │ │ ├── 20221129012510_init │ │ └── migration.sql │ │ ├── 20230112013943_add_plan │ │ └── migration.sql │ │ ├── 20221214161236_add_course_table │ │ └── migration.sql │ │ ├── 20230616090348_add_is_unessential │ │ └── migration.sql │ │ ├── 20230624215439_json_to_string │ │ └── migration.sql │ │ ├── 20230110030155_add_cyear_to_major_minor_key │ │ └── migration.sql │ │ ├── 20230920195301_cache_curriculums │ │ └── migration.sql │ │ ├── 20230614161446_add_banner_equivs │ │ └── migration.sql │ │ ├── 20230529173124_add_searchable_name │ │ └── migration.sql │ │ ├── 20231229220552_unify_packed_data │ │ └── migration.sql │ │ ├── 20230126033738_add_siding_cache_to_db │ │ └── migration.sql │ │ ├── 20230113031721_add_availability_to_course │ │ └── migration.sql │ │ ├── 20230110025406_add_major_minor_title │ │ └── migration.sql │ │ └── 20230517164749_relationalize_equivcourses │ │ └── migration.sql ├── entrypoint.sh ├── .env.default ├── .env.staging.template ├── Dockerfile ├── .env.production.template └── .env.development.template ├── frontend ├── src │ ├── utils │ │ ├── useFormModal.tsx │ │ ├── featureFlags.tsx │ │ ├── useDummyModal.tsx │ │ ├── toastConfig.tsx │ │ └── useContextMenu.tsx │ ├── assets │ │ ├── demo_gif.gif │ │ ├── instructions │ │ │ ├── quickFix.jpg │ │ │ ├── selector_full.jpg │ │ │ ├── contextMenu_mas.jpg │ │ │ ├── courseSelector.jpg │ │ │ ├── selector_delete.jpg │ │ │ ├── selector_empty.jpg │ │ │ ├── selector_full1.jpg │ │ │ ├── selector_dropdown.jpg │ │ │ ├── contextMenu_asignar.jpg │ │ │ ├── courseSelector_nofilter.jpg │ │ │ └── selector_incompatibility.jpg │ │ ├── editBlue.svg │ │ ├── editWhite.svg │ │ ├── editBlack.svg │ │ ├── starFull.svg │ │ ├── starOutline.svg │ │ ├── currentWhite.svg │ │ ├── currentBlack.svg │ │ ├── plus.svg │ │ ├── calendarWhite.svg │ │ └── calendarBlack.svg │ ├── client │ │ ├── models │ │ │ ├── HealthResponse.ts │ │ │ ├── EquivalenceId.ts │ │ │ ├── ValidationError.ts │ │ │ ├── Const.ts │ │ │ ├── HTTPValidationError.ts │ │ │ ├── ClassId.ts │ │ │ ├── Major.ts │ │ │ ├── AccessLevelOverview.ts │ │ │ ├── ReqLevel.ts │ │ │ ├── Body_generate_plan.ts │ │ │ ├── CourseOverview.ts │ │ │ ├── ReqCourse.ts │ │ │ ├── Body_get_pseudocourse_details.ts │ │ │ ├── Minor.ts │ │ │ ├── Title.ts │ │ │ ├── UnknownSpecErr.ts │ │ │ ├── ReqCareer.ts │ │ │ ├── ReqSchool.ts │ │ │ ├── ConcreteId.ts │ │ │ ├── ReqProgram.ts │ │ │ ├── CourseFilter.ts │ │ │ ├── MinCredits.ts │ │ │ ├── FullOffer.ts │ │ │ ├── UnknownCourseErr.ts │ │ │ ├── UnassignedWarn.ts │ │ │ ├── AmbiguousCourseWarn.ts │ │ │ ├── MismatchedCyearErr.ts │ │ │ ├── LowDetailPlanView.ts │ │ │ ├── UnavailableCourseWarn.ts │ │ │ ├── NoMajorMinorWarn.ts │ │ │ ├── MismatchedCurriculumSelectionWarn.ts │ │ │ ├── SemestralityWarn.ts │ │ │ ├── SemesterCreditsDiag.ts │ │ │ ├── And.ts │ │ │ ├── Or.ts │ │ │ ├── RecolorWarn.ts │ │ │ ├── PlanView.ts │ │ │ ├── StudentInfo.ts │ │ │ ├── CurriculumSpec.ts │ │ │ ├── ValidatablePlan.ts │ │ │ ├── CourseDetails.ts │ │ │ ├── CurriculumErr.ts │ │ │ ├── OutdatedPlanErr.ts │ │ │ ├── EquivDetails.ts │ │ │ ├── ValidationResult.ts │ │ │ └── CourseRequirementErr.ts │ │ └── core │ │ │ ├── ApiResult.ts │ │ │ ├── ApiRequestOptions.ts │ │ │ ├── ApiError.ts │ │ │ └── OpenAPI.ts │ ├── pages │ │ ├── errors │ │ │ ├── Error403.tsx │ │ │ └── Error404.tsx │ │ ├── planner │ │ │ ├── utils │ │ │ │ ├── ConditionalWrapper.tsx │ │ │ │ ├── ReceivePaste.tsx │ │ │ │ └── Types.tsx │ │ │ ├── ControlTopBar.tsx │ │ │ └── dialogs │ │ │ │ └── SavePlanModal.tsx │ │ ├── user │ │ │ ├── UserPage.tsx │ │ │ └── UserInfo.tsx │ │ ├── Logout.tsx │ │ ├── mod │ │ │ └── SearchPlanByRutDialog.tsx │ │ └── admin │ │ │ └── addMod.tsx │ ├── app.tsx │ ├── axios.tsx │ ├── components │ │ ├── Spinner.tsx │ │ ├── Banner.tsx │ │ ├── Info.tsx │ │ ├── DebugGraph.tsx │ │ └── GeneralModal.tsx │ ├── layout │ │ ├── Banner.tsx │ │ ├── Layout.tsx │ │ └── Footer.tsx │ ├── contexts │ │ └── confetti.context.tsx │ └── main.tsx ├── public │ └── logo.png ├── postcss.config.cjs ├── esconfig.eslint.json ├── tsconfig.node.json ├── .gitignore ├── .env.development.template ├── tsconfig.json ├── tailwind.config.cjs ├── conf │ └── Caddyfile ├── .eslintrc.cjs ├── .env.staging.template ├── vite.config.ts ├── Dockerfile ├── .env.production ├── index.html └── package.json ├── infra ├── ansible.cfg ├── requirements.yml ├── templates │ └── hosts.j2 └── productive-deploy │ └── update.sh ├── .github ├── ISSUE_TEMPLATE │ ├── other.md │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ ├── deploy.yml │ └── lint.yml ├── siding-mock-data └── index.json ├── cas-mock ├── Dockerfile └── data │ └── cas-mock-users.json.example ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── code_of_conduct.md ├── run_deploy.sh ├── .devcontainer ├── Dockerfile ├── docker-compose.dev.yml └── devcontainer.json ├── contributing.md └── docker-compose.yml /docs/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/readme.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/img/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/plan/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/sync/siding/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/typings/ortools/__init__.pyi: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/utils/useFormModal.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/app/sync/curriculums/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/typings/ortools/sat/__init__.pyi: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/typings/ortools/sat/python/__init__.pyi: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/typings/ortools/linear_solver/__init__.pyi: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /infra/ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | nocows = True 3 | 4 | [connection] 5 | pipelining = True -------------------------------------------------------------------------------- /docs/img/demo_gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/docs/img/demo_gif.gif -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/public/logo.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Otro tipo de issue 3 | about: Issue que no es un bug report 4 | --- 5 | -------------------------------------------------------------------------------- /backend/typings/cas/anyio/_core/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/demo_gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/demo_gif.gif -------------------------------------------------------------------------------- /siding-mock-data/index.json: -------------------------------------------------------------------------------- 1 | [ 2 | "mallas.json", 3 | "listado-con-versiones.json", 4 | "test-data.json" 5 | ] -------------------------------------------------------------------------------- /frontend/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /backend/typings/cas/multipart/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | __version__ = ... 6 | -------------------------------------------------------------------------------- /infra/requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | collections: 3 | - name: ansible.posix 4 | - name: community.general 5 | roles: 6 | - name: artis3n.tailscale -------------------------------------------------------------------------------- /frontend/src/assets/instructions/quickFix.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/quickFix.jpg -------------------------------------------------------------------------------- /frontend/src/assets/instructions/selector_full.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/selector_full.jpg -------------------------------------------------------------------------------- /frontend/src/assets/instructions/contextMenu_mas.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/contextMenu_mas.jpg -------------------------------------------------------------------------------- /frontend/src/assets/instructions/courseSelector.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/courseSelector.jpg -------------------------------------------------------------------------------- /frontend/src/assets/instructions/selector_delete.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/selector_delete.jpg -------------------------------------------------------------------------------- /frontend/src/assets/instructions/selector_empty.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/selector_empty.jpg -------------------------------------------------------------------------------- /frontend/src/assets/instructions/selector_full1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/selector_full1.jpg -------------------------------------------------------------------------------- /backend/typings/cas/fastapi/openapi/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | METHODS_WITH_BODY = ... 6 | REF_PREFIX = ... 7 | -------------------------------------------------------------------------------- /frontend/esconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | ".eslintrc.cjs", 5 | "vite.config.ts" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/assets/instructions/selector_dropdown.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/selector_dropdown.jpg -------------------------------------------------------------------------------- /frontend/src/assets/instructions/contextMenu_asignar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/contextMenu_asignar.jpg -------------------------------------------------------------------------------- /frontend/src/assets/instructions/courseSelector_nofilter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/courseSelector_nofilter.jpg -------------------------------------------------------------------------------- /backend/prisma/migrations/20230122024522_add_is_favorite_to_plan/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Plan" ADD COLUMN "is_favorite" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /frontend/src/assets/instructions/selector_incompatibility.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-source-uc/planner/HEAD/frontend/src/assets/instructions/selector_incompatibility.jpg -------------------------------------------------------------------------------- /frontend/src/utils/featureFlags.tsx: -------------------------------------------------------------------------------- 1 | export const hideLogin = 2 | import.meta.env.VITE_HIDE_LOGIN !== undefined && 3 | import.meta.env.VITE_HIDE_LOGIN.toLowerCase() === 'true' 4 | -------------------------------------------------------------------------------- /infra/templates/hosts.j2: -------------------------------------------------------------------------------- 1 | 127.0.0.1 planner-{{ machine_name }} localhost localhost.localdomain localhost4 localhost4.localdomain4 2 | ::1 localhost localhost.localdomain localhost6 localhost6.localdomain6 -------------------------------------------------------------------------------- /backend/app/plan/validation/courses/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Does validation on the requirements and restrictions on courses. 3 | In other words, this module handles validation that a given plan is _physically 4 | feasible_. 5 | """ 6 | -------------------------------------------------------------------------------- /backend/app/plan/validation/curriculum/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Validates that a given set of courses satisfies the requirements of a particular 3 | curriculum. 4 | Does not handle course dependencies, only curriculum requirements. 5 | """ 6 | -------------------------------------------------------------------------------- /frontend/src/client/models/HealthResponse.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type HealthResponse = { 6 | detail?: Record; 7 | }; 8 | 9 | -------------------------------------------------------------------------------- /backend/app/routes/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Routes are grouped under this module. 3 | """ 4 | 5 | from app.routes import admin, course, offer, plan, user 6 | 7 | routers = [admin.router, course.router, offer.router, plan.router, user.router] 8 | -------------------------------------------------------------------------------- /backend/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Set working directory 4 | cd /code 5 | 6 | # Apply prisma migrations 7 | prisma migrate deploy 8 | 9 | # Start FastAPI server using Gunicorn with Uvicorn workers 10 | gunicorn app.main:app 11 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20221211040818_add_course_rules/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "CourseRules" ( 3 | "id" TEXT NOT NULL, 4 | "rules" BYTEA NOT NULL, 5 | 6 | CONSTRAINT "CourseRules_pkey" PRIMARY KEY ("id") 7 | ); 8 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230716225844_reset_curriculum/migration.sql: -------------------------------------------------------------------------------- 1 | -- Resets the curriculum and equivalences so they regenerate from source. 2 | 3 | DELETE FROM "Curriculum"; 4 | DELETE FROM "EquivalenceCourse"; 5 | DELETE FROM "Equivalence"; 6 | -------------------------------------------------------------------------------- /backend/typings/cas/idna/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from typing import List, Tuple, Union 6 | 7 | __version__ = ... 8 | uts46data: Tuple[Union[Tuple[int, str], Tuple[int, str, str]], ...] = ... 9 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230516222704_clear_curriculum/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Estas a punto de resetear la tabla "Curriculum". Esta se regenerara automaticamente. 5 | 6 | */ 7 | -- Clear Curriculum. 8 | DELETE FROM "Curriculum"; 9 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230522154527_cached_course_info/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "CachedCourseInfo" ( 3 | "id" TEXT NOT NULL, 4 | "info" JSONB NOT NULL, 5 | 6 | CONSTRAINT "CachedCourseInfo_pkey" PRIMARY KEY ("id") 7 | ); 8 | -------------------------------------------------------------------------------- /backend/app/database.py: -------------------------------------------------------------------------------- 1 | from prisma import Prisma 2 | 3 | # Prisma gestiona la conexión a la base de datos 4 | # al recibir `DATABASE_URL` como variable de entorno mediante el .env 5 | # (Si, prisma es así de mágico) 6 | 7 | prisma = Prisma(auto_register=True) 8 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230108052921_drop_posts_table/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Post` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE "Post"; 9 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230721211317_normalize_ruts/migration.sql: -------------------------------------------------------------------------------- 1 | -- Remove leading zeros from RUTs. 2 | 3 | UPDATE "AccessLevel" 4 | SET user_rut = TRIM(LEADING '0' FROM user_rut); 5 | 6 | UPDATE "Plan" 7 | SET user_rut = TRIM(LEADING '0' FROM user_rut); 8 | -------------------------------------------------------------------------------- /frontend/src/client/models/EquivalenceId.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type EquivalenceId = { 6 | is_concrete?: boolean; 7 | code: string; 8 | credits: number; 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/client/models/ValidationError.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type ValidationError = { 6 | loc: Array<(string | number)>; 7 | msg: string; 8 | type: string; 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20221214170908_drop_course_rules_cache/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `CourseRules` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE "CourseRules"; 9 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230627211411_add_inv_equivs/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Course" ADD COLUMN "banner_inv_equivs" TEXT[]; 3 | DELETE FROM "EquivalenceCourse"; 4 | DELETE FROM "Equivalence"; 5 | DELETE FROM "Course"; 6 | DELETE FROM "CachedCourseInfo"; 7 | -------------------------------------------------------------------------------- /frontend/src/client/models/Const.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * A constant, fixed value of True or False. 7 | */ 8 | export type Const = { 9 | expr?: 'const'; 10 | value: boolean; 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /backend/typings/cas/fastapi/security/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from typing import Optional, Tuple 6 | 7 | def get_authorization_scheme_param(authorization_header_value: Optional[str]) -> Tuple[str, str]: 8 | ... 9 | 10 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230110033455_remove_example_curriculum/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `CurriculumBlock` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE "CurriculumBlock"; 9 | -------------------------------------------------------------------------------- /backend/scripts/prestart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script is used as a prelaunch script when running in production 3 | 4 | # Set working directory 5 | cd /app 6 | 7 | # Apply prisma migrations 8 | prisma migrate deploy 9 | 10 | # Run the startup script 11 | python ./scripts/startup.py -------------------------------------------------------------------------------- /backend/prisma/migrations/20230516163839_remove_planrecommendation/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `PlanRecommendation` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE "PlanRecommendation"; 9 | -------------------------------------------------------------------------------- /backend/typings/cas/prisma/binaries/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from . import platform 6 | 7 | __all__ = ('PRISMA_CLI_NAME', ) 8 | if platform.name() == 'windows': 9 | PRISMA_CLI_NAME = ... 10 | else: 11 | PRISMA_CLI_NAME = ... 12 | -------------------------------------------------------------------------------- /frontend/src/client/models/HTTPValidationError.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ValidationError } from './ValidationError'; 6 | 7 | export type HTTPValidationError = { 8 | detail?: Array; 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/client/models/ClassId.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * A somewhat stable identifier for a course instance within a plan. 7 | */ 8 | export type ClassId = { 9 | code: string; 10 | instance: number; 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/client/models/Major.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Represents a Major record 7 | */ 8 | export type Major = { 9 | cyear: string; 10 | code: string; 11 | name: string; 12 | version: string; 13 | }; 14 | 15 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20221219025329_add_curriculums/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "CurriculumBlock" ( 3 | "id" TEXT NOT NULL, 4 | "kind" TEXT NOT NULL, 5 | "name" TEXT NOT NULL, 6 | "req" JSONB NOT NULL, 7 | 8 | CONSTRAINT "CurriculumBlock_pkey" PRIMARY KEY ("id") 9 | ); 10 | -------------------------------------------------------------------------------- /frontend/src/client/core/ApiResult.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | export type ApiResult = { 5 | readonly url: string; 6 | readonly ok: boolean; 7 | readonly status: number; 8 | readonly statusText: string; 9 | readonly body: any; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/pages/errors/Error403.tsx: -------------------------------------------------------------------------------- 1 | 2 | const Error403 = (): JSX.Element => { 3 | return ( 4 |
5 |

403- Acceso denegado

6 |
7 | ) 8 | } 9 | 10 | export default Error403 11 | -------------------------------------------------------------------------------- /backend/app/sync/curriculums/scrape/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Este codigo se encarga de procesar el texto human-readable proveniente de los PDFs de 3 | plan de estudios y convertirlos en datos computer-readable. 4 | 5 | Dado que el codigo es muy especifico para la UC, los comentarios están en español en 6 | muchos lugares. 7 | """ 8 | -------------------------------------------------------------------------------- /frontend/src/client/models/AccessLevelOverview.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type AccessLevelOverview = { 6 | name?: string; 7 | user_rut: string; 8 | is_mod: boolean; 9 | created_at: string; 10 | updated_at: string; 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/client/models/ReqLevel.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Express that this course requires a certain academic level. 7 | */ 8 | export type ReqLevel = { 9 | expr?: 'lvl'; 10 | level: string; 11 | equal: boolean; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/pages/errors/Error404.tsx: -------------------------------------------------------------------------------- 1 | 2 | const Error404 = (): JSX.Element => { 3 | return ( 4 |
5 |

404- Pagina no encontrada

6 |
7 | ) 8 | } 9 | 10 | export default Error404 11 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230114200140_add_equivalence_data/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Equivalence" ( 3 | "code" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "is_homogeneous" BOOLEAN NOT NULL, 6 | "courses" TEXT[], 7 | 8 | CONSTRAINT "Equivalence_pkey" PRIMARY KEY ("code") 9 | ); 10 | -------------------------------------------------------------------------------- /frontend/src/client/models/Body_generate_plan.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ValidatablePlan } from './ValidatablePlan'; 6 | 7 | export type Body_generate_plan = { 8 | passed: ValidatablePlan; 9 | reference?: ValidatablePlan; 10 | }; 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/client/models/CourseOverview.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type CourseOverview = { 6 | code: string; 7 | name: string; 8 | credits: number; 9 | school: string; 10 | area?: string; 11 | is_available: boolean; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/client/models/ReqCourse.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Require the student to have taken a course in the previous semesters. 7 | */ 8 | export type ReqCourse = { 9 | expr?: 'req'; 10 | code: string; 11 | coreq: boolean; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230110030021_add_cyear_to_major_minor/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `cyear` to the `MajorMinor` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "MajorMinor" ADD COLUMN "cyear" TEXT NOT NULL; 9 | -------------------------------------------------------------------------------- /frontend/src/client/models/Body_get_pseudocourse_details.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { CurriculumSpec } from './CurriculumSpec'; 6 | 7 | export type Body_get_pseudocourse_details = { 8 | codes: Array; 9 | plan?: CurriculumSpec; 10 | }; 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/client/models/Minor.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Represents a Minor record 7 | */ 8 | export type Minor = { 9 | cyear: string; 10 | code: string; 11 | name: string; 12 | version: string; 13 | minor_type: string; 14 | }; 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/client/models/Title.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Represents a Title record 7 | */ 8 | export type Title = { 9 | cyear: string; 10 | code: string; 11 | name: string; 12 | version: string; 13 | title_type: string; 14 | }; 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/client/models/UnknownSpecErr.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type UnknownSpecErr = { 6 | is_err?: boolean; 7 | kind?: 'unkspec'; 8 | associated_to?: null; 9 | major: boolean; 10 | minor: boolean; 11 | title: boolean; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230523160411_preserve_equiv_order/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `index` to the `EquivalenceCourse` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "EquivalenceCourse" ADD COLUMN "index" INTEGER NOT NULL; 9 | -------------------------------------------------------------------------------- /frontend/src/client/models/ReqCareer.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Express that this course requires the student to belong to a particular career. 7 | */ 8 | export type ReqCareer = { 9 | expr?: 'career'; 10 | career: string; 11 | equal: boolean; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/client/models/ReqSchool.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Express that this course requires the student to belong to a particular school. 7 | */ 8 | export type ReqSchool = { 9 | expr?: 'school'; 10 | school: string; 11 | equal: boolean; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/client/models/ConcreteId.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { EquivalenceId } from './EquivalenceId'; 6 | 7 | export type ConcreteId = { 8 | is_concrete?: boolean; 9 | code: string; 10 | equivalence?: EquivalenceId; 11 | failed?: string; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/client/models/ReqProgram.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Express that this course requires the student to belong to a particular program. 7 | */ 8 | export type ReqProgram = { 9 | expr?: 'program'; 10 | program: string; 11 | equal: boolean; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230602043939_remove_tav/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `semestrality_tav` on the `Course` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Course" DROP COLUMN "semestrality_tav"; 9 | DELETE FROM "CachedCourseInfo" WHERE id = 'cached-course-info'; 10 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20231004172806_update_plan_cyear/migration.sql: -------------------------------------------------------------------------------- 1 | -- Update the curriculum.cyear field 2 | -- Do the operation `plan.curriculum.cyear = plan.curriculum.cyear.raw` for all plans. 3 | 4 | UPDATE "Plan" 5 | SET validatable_plan = jsonb_set(validatable_plan::jsonb, '{curriculum,cyear}', validatable_plan::jsonb -> 'curriculum' -> 'cyear' -> 'raw') #>> '{}'; 6 | -------------------------------------------------------------------------------- /frontend/src/client/models/CourseFilter.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | export type CourseFilter = { 6 | text?: string; 7 | credits?: number; 8 | school?: string; 9 | available?: boolean; 10 | first_semester?: boolean; 11 | second_semester?: boolean; 12 | equiv?: string; 13 | }; 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/client/models/MinCredits.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * A restriction that is only satisfied if the total amount of credits in the previous 7 | * semesters is over a certain threshold. 8 | */ 9 | export type MinCredits = { 10 | expr?: 'cred'; 11 | min_credits: number; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230529093144_add_access_level/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "AccessLevel" ( 3 | "user_rut" TEXT NOT NULL, 4 | "is_mod" BOOLEAN NOT NULL, 5 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updated_at" TIMESTAMP(3) NOT NULL, 7 | 8 | CONSTRAINT "AccessLevel_pkey" PRIMARY KEY ("user_rut") 9 | ); 10 | -------------------------------------------------------------------------------- /frontend/src/client/models/FullOffer.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { Major } from './Major'; 6 | import type { Minor } from './Minor'; 7 | import type { Title } from './Title'; 8 | 9 | export type FullOffer = { 10 | majors: Array; 11 | minors: Array; 12 | titles: Array; 13 | }; 14 | 15 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20221129012510_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Post" ( 3 | "id" TEXT NOT NULL, 4 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updated_at" TIMESTAMP(3) NOT NULL, 6 | "title" TEXT NOT NULL, 7 | "published" BOOLEAN NOT NULL, 8 | "desc" TEXT, 9 | 10 | CONSTRAINT "Post_pkey" PRIMARY KEY ("id") 11 | ); 12 | -------------------------------------------------------------------------------- /backend/typings/cas/click/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import io 6 | import sys 7 | import time 8 | import typing as t 9 | from ctypes import POINTER, Structure, byref, c_char, c_char_p, c_int, c_ssize_t, c_ulong, c_void_p, py_object 10 | from ctypes.wintypes import DWORD, HANDLE, LPCWSTR, LPWSTR 11 | from ._compat import _NonClosingTextIOWrapper 12 | 13 | -------------------------------------------------------------------------------- /backend/typings/cas/lxml/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | __version__ = ... 6 | def get_include(): # -> list[str | Unknown]: 7 | """ 8 | Returns a list of header include paths (for lxml itself, libxml2 9 | and libxslt) needed to compile C code against lxml if it was built 10 | with statically linked libraries. 11 | """ 12 | ... 13 | 14 | -------------------------------------------------------------------------------- /cas-mock/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | WORKDIR /app 4 | 5 | # Install curl for healthcheck 6 | RUN apk --no-cache add curl 7 | 8 | EXPOSE 3004 9 | COPY ./data/cas-mock-users.json.example /app/data/cas-mock-users.json 10 | 11 | HEALTHCHECK CMD curl --fail http://localhost:3004/ || exit 1 12 | 13 | CMD ["npx", "--yes", "cas-server-mock", "--port=3004", "--database=/app/data/cas-mock-users.json"] 14 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230112013943_add_plan/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Plan" ( 3 | "id" TEXT NOT NULL, 4 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updated_at" TIMESTAMP(3) NOT NULL, 6 | "name" TEXT NOT NULL, 7 | "user_rut" TEXT NOT NULL, 8 | "validatable_plan" JSONB NOT NULL, 9 | 10 | CONSTRAINT "Plan_pkey" PRIMARY KEY ("id") 11 | ); 12 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20221214161236_add_course_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Course" ( 3 | "code" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "credits" INTEGER NOT NULL, 6 | "deps" JSONB NOT NULL, 7 | "program" TEXT NOT NULL, 8 | "school" TEXT NOT NULL, 9 | "area" TEXT, 10 | "category" TEXT, 11 | 12 | CONSTRAINT "Course_pkey" PRIMARY KEY ("code") 13 | ); 14 | -------------------------------------------------------------------------------- /frontend/src/client/models/UnknownCourseErr.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ClassId } from './ClassId'; 6 | 7 | /** 8 | * Indicates that some courses (`associated_to`) have unknown/invalid codes. 9 | */ 10 | export type UnknownCourseErr = { 11 | is_err?: boolean; 12 | kind?: 'unknown'; 13 | associated_to: Array<ClassId>; 14 | }; 15 | 16 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230616090348_add_is_unessential/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `is_unessential` to the `Equivalence` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | DELETE FROM "EquivalenceCourse"; 9 | DELETE FROM "Equivalence"; 10 | ALTER TABLE "Equivalence" ADD COLUMN "is_unessential" BOOLEAN NOT NULL; 11 | -------------------------------------------------------------------------------- /frontend/src/client/models/UnassignedWarn.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Indicates that some courses (in total `unassigned_credits` credits) have no use in 7 | * the curriculum. 8 | */ 9 | export type UnassignedWarn = { 10 | is_err?: boolean; 11 | kind?: 'useless'; 12 | associated_to?: null; 13 | unassigned_credits: number; 14 | }; 15 | 16 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-vscode-remote.remote-containers", 4 | "kokakiwi.vscode-just", 5 | "ms-python.python", 6 | "charliermarsh.ruff", 7 | "prisma.prisma", 8 | "bradlc.vscode-tailwindcss", 9 | "austenc.tailwind-docs", 10 | "dbaeumer.vscode-eslint", 11 | "redhat.ansible", 12 | "github.vscode-github-actions" 13 | ] 14 | } -------------------------------------------------------------------------------- /backend/prisma/migrations/20230624215439_json_to_string/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "CachedCourseInfo" ALTER COLUMN "info" SET DATA TYPE TEXT; 3 | 4 | -- AlterTable 5 | ALTER TABLE "Course" ALTER COLUMN "deps" SET DATA TYPE TEXT; 6 | 7 | -- AlterTable 8 | ALTER TABLE "Curriculum" ALTER COLUMN "curriculum" SET DATA TYPE TEXT; 9 | 10 | -- AlterTable 11 | ALTER TABLE "Plan" ALTER COLUMN "validatable_plan" SET DATA TYPE TEXT; 12 | -------------------------------------------------------------------------------- /frontend/src/pages/planner/utils/ConditionalWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react' 2 | interface ConditionalWrapperProps { 3 | condition: boolean 4 | wrapper: Function 5 | children: ReactNode 6 | } 7 | 8 | const ConditionalWrapper = ({ condition, wrapper, children }: ConditionalWrapperProps): JSX.Element => { 9 | return ( 10 | condition ? wrapper(children) : children 11 | ) 12 | } 13 | export default ConditionalWrapper 14 | -------------------------------------------------------------------------------- /frontend/src/client/models/AmbiguousCourseWarn.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ClassId } from './ClassId'; 6 | 7 | /** 8 | * Indicates that some equivalences (`associated_to`) should be disambiguated and they 9 | * aren't. 10 | */ 11 | export type AmbiguousCourseWarn = { 12 | is_err?: boolean; 13 | kind?: 'equiv'; 14 | associated_to: Array<ClassId>; 15 | }; 16 | 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .eslintcache 26 | 27 | # Sentry Auth Token 28 | .env.sentry-build-plugin 29 | -------------------------------------------------------------------------------- /frontend/src/client/models/MismatchedCyearErr.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Indicates that the plan is validating for a cyear (`plan`) that does not match the 7 | * user's cyear (`user`). 8 | */ 9 | export type MismatchedCyearErr = { 10 | is_err?: boolean; 11 | kind?: 'cyear'; 12 | associated_to?: null; 13 | plan: ('C2020' | 'C2022'); 14 | user: string; 15 | }; 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/client/models/LowDetailPlanView.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Lighter version of the PlanView model. 7 | * This should only contain the required attributes to show the user their plans list 8 | */ 9 | export type LowDetailPlanView = { 10 | id: string; 11 | created_at: string; 12 | updated_at: string; 13 | name: string; 14 | is_favorite: boolean; 15 | }; 16 | 17 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230110030155_add_cyear_to_major_minor_key/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[cyear,major,minor]` on the table `MajorMinor` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- DropIndex 8 | DROP INDEX "MajorMinor_major_minor_key"; 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "MajorMinor_cyear_major_minor_key" ON "MajorMinor"("cyear", "major", "minor"); 12 | -------------------------------------------------------------------------------- /frontend/src/client/models/UnavailableCourseWarn.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ClassId } from './ClassId'; 6 | 7 | /** 8 | * Indicates that some courses (`associated_to`) have not been given in a long while 9 | * and are probably unavailable. 10 | */ 11 | export type UnavailableCourseWarn = { 12 | is_err?: boolean; 13 | kind?: 'unavail'; 14 | associated_to: Array<ClassId>; 15 | }; 16 | 17 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230920195301_cache_curriculums/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Curriculum` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE "Curriculum"; 9 | 10 | -- CreateTable 11 | CREATE TABLE "CachedCurriculum" ( 12 | "id" TEXT NOT NULL, 13 | "curriculums" TEXT NOT NULL, 14 | 15 | CONSTRAINT "CachedCurriculum_pkey" PRIMARY KEY ("id") 16 | ); 17 | -------------------------------------------------------------------------------- /frontend/src/client/models/NoMajorMinorWarn.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { CurriculumSpec } from './CurriculumSpec'; 6 | 7 | /** 8 | * Indicates that no major or minor is chosen, and it should be chosen to validate the 9 | * plan correctly. 10 | */ 11 | export type NoMajorMinorWarn = { 12 | is_err?: boolean; 13 | kind?: 'nomajor'; 14 | associated_to?: null; 15 | plan: CurriculumSpec; 16 | }; 17 | 18 | -------------------------------------------------------------------------------- /frontend/.env.development.template: -------------------------------------------------------------------------------- 1 | # URL Used by caddy to redirect to the planner. 2 | PLANNER_URL="http://localhost:3000" 3 | 4 | VITE_BASE_API_URL="/api" 5 | 6 | BACKEND_API_URL="http://localhost:8000" 7 | 8 | # Whether to hide the login options 9 | VITE_HIDE_LOGIN = "false" 10 | 11 | # By default set to the mock SSO 12 | # If you're an authorized developer 13 | # you can use https://sso-lib.uc.cl/cas/ 14 | VITE_CAS_SERVER_URL="http://localhost:3004/" 15 | 16 | # Don't remove 17 | NODE_ENV="development" 18 | -------------------------------------------------------------------------------- /frontend/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { AuthProvider, useToken } from './contexts/auth.context' 2 | import { RouterProvider } from '@tanstack/react-router' 3 | 4 | import router from './router' 5 | import { ConfettiProvider } from './contexts/confetti.context' 6 | 7 | export default function App (): JSX.Element { 8 | return ( 9 | <ConfettiProvider> 10 | <AuthProvider userData={useToken()}> 11 | <RouterProvider router={router}/> 12 | </AuthProvider> 13 | </ConfettiProvider> 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230614161446_add_banner_equivs/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `canonical_equiv` to the `Course` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | DELETE FROM "EquivalenceCourse"; 9 | DELETE FROM "Equivalence"; 10 | DELETE FROM "Course"; 11 | ALTER TABLE "Course" ADD COLUMN "banner_equivs" TEXT[], 12 | ADD COLUMN "canonical_equiv" TEXT NOT NULL; 13 | DELETE FROM "CachedCourseInfo"; 14 | -------------------------------------------------------------------------------- /backend/scripts/dump_curriculums.py: -------------------------------------------------------------------------------- 1 | """ 2 | Debug tool to dump the curriculum definitions out of the database. 3 | The startup script must have been run before. 4 | """ 5 | 6 | from app.database import prisma 7 | from app.sync.database import CURRICULUMS_PACK_ID, load_packed 8 | 9 | 10 | async def collate(): 11 | async with prisma: 12 | packed = await load_packed(CURRICULUMS_PACK_ID) 13 | print(packed) 14 | 15 | 16 | if __name__ == "__main__": 17 | import asyncio 18 | 19 | asyncio.run(collate()) 20 | -------------------------------------------------------------------------------- /backend/typings/cas/fastapi/middleware/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from typing import Optional 6 | from fastapi.concurrency import AsyncExitStack 7 | from starlette.types import ASGIApp, Receive, Scope, Send 8 | 9 | class AsyncExitStackMiddleware: 10 | def __init__(self, app: ASGIApp, context_name: str = ...) -> None: 11 | ... 12 | 13 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 14 | ... 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /backend/typings/cas/httpcore/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import select 6 | import socket 7 | import sys 8 | import typing 9 | 10 | def is_socket_readable(sock: typing.Optional[socket.socket]) -> bool: 11 | """ 12 | Return whether a socket, as identifed by its file descriptor, is readable. 13 | "A socket is readable" means that the read buffer isn't empty, i.e. that calling 14 | .recv() on it would immediately return some data. 15 | """ 16 | ... 17 | 18 | -------------------------------------------------------------------------------- /frontend/src/assets/editBlue.svg: -------------------------------------------------------------------------------- 1 | <svg width="12" height="12" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M9.99976 4L12.9998 7M1.46498 15.9635L4.44962 14.9998C4.85903 14.8676 5.23522 14.6489 5.55266 14.3585L15.1369 5.59153C16.0791 4.72963 16.2114 3.29257 15.4424 2.27312C14.5365 1.07233 12.7781 0.949827 11.7146 2.01342L2.5112 11.2168C2.17511 11.5529 1.92369 11.964 1.77765 12.4163L0.83554 15.3341C0.71027 15.722 1.07702 16.0888 1.46498 15.9635Z" stroke="blue" stroke-width="1.5" stroke-linecap="round"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /frontend/src/assets/editWhite.svg: -------------------------------------------------------------------------------- 1 | <svg width="12" height="12" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M9.99976 4L12.9998 7M1.46498 15.9635L4.44962 14.9998C4.85903 14.8676 5.23522 14.6489 5.55266 14.3585L15.1369 5.59153C16.0791 4.72963 16.2114 3.29257 15.4424 2.27312C14.5365 1.07233 12.7781 0.949827 11.7146 2.01342L2.5112 11.2168C2.17511 11.5529 1.92369 11.964 1.77765 12.4163L0.83554 15.3341C0.71027 15.722 1.07702 16.0888 1.46498 15.9635Z" stroke="white" stroke-width="1.5" stroke-linecap="round"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /frontend/src/pages/user/UserPage.tsx: -------------------------------------------------------------------------------- 1 | // import UserInfo from '../components/UserInfo' 2 | import CurriculumList from './CurriculumList' 3 | 4 | /** 5 | * The user page. Contains the list of curriculums and some user information. 6 | */ 7 | const UserPage = (): JSX.Element => { 8 | return ( 9 | 10 | <div className="flex my-2 h-full"> 11 | <div className="mx-auto"> <CurriculumList /></div> 12 | {/* <div className="w-1/3 bg-blue-100"><UserInfo /></div> */} 13 | </div> 14 | 15 | ) 16 | } 17 | 18 | export default UserPage 19 | -------------------------------------------------------------------------------- /backend/typings/cas/certifi/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import sys 6 | from importlib.resources import as_file, files 7 | 8 | """ 9 | certifi.py 10 | ~~~~~~~~~~ 11 | 12 | This module returns the installation location of cacert.pem or its contents. 13 | """ 14 | if sys.version_info >= (3, 11): 15 | _CACERT_CTX = ... 16 | _CACERT_PATH = ... 17 | def where() -> str: 18 | ... 19 | 20 | def contents() -> str: 21 | ... 22 | 23 | else: 24 | ... 25 | -------------------------------------------------------------------------------- /frontend/src/assets/editBlack.svg: -------------------------------------------------------------------------------- 1 | <svg width="12" height="12" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M9.99976 4L12.9998 7M1.46498 15.9635L4.44962 14.9998C4.85903 14.8676 5.23522 14.6489 5.55266 14.3585L15.1369 5.59153C16.0791 4.72963 16.2114 3.29257 15.4424 2.27312C14.5365 1.07233 12.7781 0.949827 11.7146 2.01342L2.5112 11.2168C2.17511 11.5529 1.92369 11.964 1.77765 12.4163L0.83554 15.3341C0.71027 15.722 1.07702 16.0888 1.46498 15.9635Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /frontend/src/client/models/MismatchedCurriculumSelectionWarn.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { CurriculumSpec } from './CurriculumSpec'; 6 | 7 | /** 8 | * Indicates that the plan selection of curriculum does not match the official 9 | * curriculum declaration. 10 | */ 11 | export type MismatchedCurriculumSelectionWarn = { 12 | is_err?: boolean; 13 | kind?: 'currdecl'; 14 | associated_to?: null; 15 | plan: CurriculumSpec; 16 | user: CurriculumSpec; 17 | }; 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/assets/starFull.svg: -------------------------------------------------------------------------------- 1 | <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M10.7878 3.10263C11.283 2.09926 12.7138 2.09925 13.209 3.10263L15.567 7.88036L20.8395 8.6465C21.9468 8.8074 22.3889 10.1682 21.5877 10.9492L17.7724 14.6681L18.6731 19.9193C18.8622 21.0222 17.7047 21.8632 16.7143 21.3425L11.9984 18.8632L7.28252 21.3425C6.29213 21.8632 5.13459 21.0222 5.32374 19.9193L6.2244 14.6681L2.40916 10.9492C1.60791 10.1682 2.05005 8.8074 3.15735 8.6465L8.42988 7.88036L10.7878 3.10263Z" fill="#facc15"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /backend/app/sync/curriculums/scrape/major.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from pathlib import Path 4 | 5 | from app.plan.validation.curriculum.tree import MajorCode 6 | 7 | log = logging.getLogger("plan-collator") 8 | 9 | REGEX_MAJOR_CODE = re.compile(r"\((M\d{3})\)") 10 | 11 | 12 | def scrape_majors() -> set[MajorCode]: 13 | log.debug("scraping majors...") 14 | 15 | # Load raw pre-scraped text 16 | raw = Path("../static-curriculum-data/major-scrape.txt").read_text() 17 | 18 | return {MajorCode(code) for code in REGEX_MAJOR_CODE.findall(raw)} 19 | -------------------------------------------------------------------------------- /frontend/src/client/models/SemestralityWarn.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ClassId } from './ClassId'; 6 | 7 | /** 8 | * Indicates that some courses (`associated_to`) are not normally given in the 9 | * semester they are in. 10 | * Instead, they are usually only given in semesters with parity `only_available_on`. 11 | */ 12 | export type SemestralityWarn = { 13 | is_err?: boolean; 14 | kind?: 'sem'; 15 | associated_to: Array<ClassId>; 16 | only_available_on: number; 17 | }; 18 | 19 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230529173124_add_searchable_name/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `searchable_name` to the `Course` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | 8 | -- Clear course cache so that it is redownloaded from the new source. 9 | DELETE FROM "EquivalenceCourse"; 10 | DELETE FROM "Equivalence"; 11 | DELETE FROM "Course"; 12 | DELETE FROM "CachedCourseInfo" WHERE id = 'cached-course-info'; 13 | 14 | -- AlterTable 15 | ALTER TABLE "Course" ADD COLUMN "searchable_name" TEXT NOT NULL; 16 | -------------------------------------------------------------------------------- /frontend/src/client/models/SemesterCreditsDiag.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Indicates that some semesters (`associated_to`) have more than the recommended or 7 | * allowed amount of credits. 8 | * 9 | * If `is_err` is `True`, the hard limit was surpassed. 10 | * If `is_err` is `False`, only the soft limit was surpassed. 11 | */ 12 | export type SemesterCreditsDiag = { 13 | is_err: boolean; 14 | kind?: 'credits'; 15 | associated_to: Array<number>; 16 | credit_limit: number; 17 | actual: number; 18 | }; 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/utils/useDummyModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | interface ModalState { 4 | isModalOpen: boolean 5 | openModal: Function 6 | closeModal: Function 7 | } 8 | 9 | const useDummyModal = (): ModalState => { 10 | const [isModalOpen, setIsModalOpen] = useState(false) 11 | 12 | const openModal = (): void => { 13 | setIsModalOpen(true) 14 | } 15 | 16 | const closeModal = (): void => { 17 | setIsModalOpen(false) 18 | } 19 | 20 | return { 21 | isModalOpen, 22 | openModal, 23 | closeModal 24 | } 25 | } 26 | 27 | export default useDummyModal 28 | -------------------------------------------------------------------------------- /frontend/src/assets/starOutline.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" ?><svg fill="none" height="24" stroke-width="1.5" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M8.58737 8.23597L11.1849 3.00376C11.5183 2.33208 12.4817 2.33208 12.8151 3.00376L15.4126 8.23597L21.2215 9.08017C21.9668 9.18848 22.2638 10.0994 21.7243 10.6219L17.5217 14.6918L18.5135 20.4414C18.6409 21.1798 17.8614 21.7428 17.1945 21.3941L12 18.678L6.80547 21.3941C6.1386 21.7428 5.35909 21.1798 5.48645 20.4414L6.47825 14.6918L2.27575 10.6219C1.73617 10.0994 2.03322 9.18848 2.77852 9.08017L8.58737 8.23597Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/></svg> -------------------------------------------------------------------------------- /frontend/src/axios.tsx: -------------------------------------------------------------------------------- 1 | import Axios from 'axios' 2 | 3 | const axios = Axios.create({ 4 | baseURL: import.meta.env.VITE_BASE_API_URL 5 | }) 6 | 7 | // Inject the token into the request header 8 | axios.interceptors.request.use((config) => { 9 | const token = localStorage.getItem('access-token') 10 | if (token != null) { 11 | config.headers.Authorization = `Bearer ${token}` 12 | } 13 | return config 14 | }) 15 | 16 | // The exported axios can be used to make requests 17 | // to the API without worrying about base URLs or using the token 18 | // However it is recommended to use the generated client instead 19 | export default axios 20 | -------------------------------------------------------------------------------- /frontend/src/utils/toastConfig.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify' 2 | 3 | export function toastConfig (): void { 4 | toast.onChange((payload: any) => { 5 | if (payload.status === 'removed' && payload.type === toast.TYPE.ERROR && payload.id === 'ERROR401') { 6 | window.location.href = `${import.meta.env.VITE_BASE_API_URL as string}/user/login` 7 | localStorage.removeItem('access-token') 8 | } else if (payload.status === 'removed' && payload.type === toast.TYPE.ERROR && payload.id === 'ERROR403') { 9 | window.location.href = '/logout' 10 | localStorage.removeItem('access-token') 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20231229220552_unify_packed_data/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `CachedCourseInfo` table. If the table is not empty, all the data it contains will be lost. 5 | - You are about to drop the `CachedCurriculum` table. If the table is not empty, all the data it contains will be lost. 6 | 7 | */ 8 | -- DropTable 9 | DROP TABLE "CachedCourseInfo"; 10 | 11 | -- DropTable 12 | DROP TABLE "CachedCurriculum"; 13 | 14 | -- CreateTable 15 | CREATE TABLE "PackedData" ( 16 | "id" TEXT NOT NULL, 17 | "data" TEXT NOT NULL, 18 | 19 | CONSTRAINT "PackedData_pkey" PRIMARY KEY ("id") 20 | ); 21 | -------------------------------------------------------------------------------- /backend/typings/cas/fastapi/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import anyio 6 | from contextlib import AsyncExitStack as AsyncExitStack, asynccontextmanager as asynccontextmanager 7 | from typing import AsyncGenerator, ContextManager, TypeVar 8 | from anyio import CapacityLimiter 9 | from starlette.concurrency import iterate_in_threadpool as iterate_in_threadpool, run_in_threadpool as run_in_threadpool, run_until_first_complete as run_until_first_complete 10 | 11 | _T = TypeVar("_T") 12 | @asynccontextmanager 13 | async def contextmanager_in_threadpool(cm: ContextManager[_T]) -> AsyncGenerator[_T, None]: 14 | ... 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/client/core/ApiRequestOptions.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | export type ApiRequestOptions = { 5 | readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; 6 | readonly url: string; 7 | readonly path?: Record<string, any>; 8 | readonly cookies?: Record<string, any>; 9 | readonly headers?: Record<string, any>; 10 | readonly query?: Record<string, any>; 11 | readonly formData?: Record<string, any>; 12 | readonly body?: any; 13 | readonly mediaType?: string; 14 | readonly responseHeader?: string; 15 | readonly errors?: Record<number, string>; 16 | }; 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Reporte de Bug 🐛 2 | description: Encontraste algo que no funciona como debería 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Muchas gracias por tomarte el tiempo de reportar un problema :heart: 10 | 11 | Antes de subir la issue, por favor, revisa las issues pineadas e intenta 12 | buscar si tu problema no ha sido reportado en otra issue. 13 | 14 | - type: textarea 15 | id: descripcion 16 | attributes: 17 | label: Descripción 18 | description: Describe el problema que encontraste 19 | validations: 20 | required: true 21 | -------------------------------------------------------------------------------- /frontend/src/pages/Logout.tsx: -------------------------------------------------------------------------------- 1 | const Logout = (): JSX.Element => { 2 | // Clear token from local storage 3 | localStorage.removeItem('access-token') 4 | 5 | const casURL: string = import.meta.env.VITE_CAS_SERVER_URL 6 | console.assert(casURL, 'VITE_CAS_SERVER_URL environment variable not set during build') 7 | 8 | // Redirect to SSO logout URL 9 | const ssoLogoutURL = import.meta.env.DEV ? '/' : new URL('logout', casURL).toString() 10 | window.location.href = ssoLogoutURL 11 | 12 | return <div className="mx-auto my-auto"> 13 | <h2 className="font-bold text-2xl"> 14 | Cerrando sesión...   👋 15 | </h2> 16 | </div> 17 | } 18 | 19 | export default Logout 20 | -------------------------------------------------------------------------------- /code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Reglas básicas 2 | 3 | 1. Trata a todo el mundo con respeto. No se tolerará ningún tipo de acoso, caza de brujas, sexismo, racismo o discurso de odio. 4 | 5 | 2. No se permite el spam ni la autopromoción (invitaciones al servidor, anuncios, etc.) sin permiso de un miembro del personal. Esto también incluye mandar MD a otros miembros. 6 | 7 | 3. No se permite contenido NSFW ni obsceno. Esto incluye texto, imágenes o enlaces que presenten desnudos, sexo, violencia u otro tipo de contenido gráfico que pueda herir la sensibilidad del espectador. 8 | 9 | 4. Si ves algo que va en contra de las normas o que no te haga sentir seguro, informa al personal. ¡Queremos que este proyecto sea un lugar acogedor! 10 | -------------------------------------------------------------------------------- /frontend/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | interface SpinnerProps { 2 | message: string 3 | } 4 | 5 | export function Spinner ({ message }: SpinnerProps): JSX.Element { 6 | return <div className='mx-auto my-auto max-w-20 max-h-10'> 7 | <svg className="animate-spin h-10 w-10 mx-auto mb-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> 8 | <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> 9 | <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 10 | </svg> 11 | <p className="text-center">{message}</p> 12 | </div> 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/client/models/And.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { Const } from './Const'; 6 | import type { MinCredits } from './MinCredits'; 7 | import type { Or } from './Or'; 8 | import type { ReqCareer } from './ReqCareer'; 9 | import type { ReqCourse } from './ReqCourse'; 10 | import type { ReqLevel } from './ReqLevel'; 11 | import type { ReqProgram } from './ReqProgram'; 12 | import type { ReqSchool } from './ReqSchool'; 13 | 14 | /** 15 | * Logical AND connector. 16 | * Only satisfied if all of its children are satisfied. 17 | */ 18 | export type And = { 19 | expr?: 'and'; 20 | children: Array<(And | Or | Const | MinCredits | ReqLevel | ReqSchool | ReqProgram | ReqCareer | ReqCourse)>; 21 | }; 22 | 23 | -------------------------------------------------------------------------------- /frontend/src/client/models/Or.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { And } from './And'; 6 | import type { Const } from './Const'; 7 | import type { MinCredits } from './MinCredits'; 8 | import type { ReqCareer } from './ReqCareer'; 9 | import type { ReqCourse } from './ReqCourse'; 10 | import type { ReqLevel } from './ReqLevel'; 11 | import type { ReqProgram } from './ReqProgram'; 12 | import type { ReqSchool } from './ReqSchool'; 13 | 14 | /** 15 | * Logical OR connector. 16 | * Only satisfied if at least one of its children is satisfied. 17 | */ 18 | export type Or = { 19 | expr?: 'or'; 20 | children: Array<(And | Or | Const | MinCredits | ReqLevel | ReqSchool | ReqProgram | ReqCareer | ReqCourse)>; 21 | }; 22 | 23 | -------------------------------------------------------------------------------- /frontend/src/client/models/RecolorWarn.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ClassId } from './ClassId'; 6 | import type { EquivalenceId } from './EquivalenceId'; 7 | 8 | /** 9 | * Indicates that reassigning the equivalences that are attached to the courses could 10 | * save some unnecessary classes. 11 | * Reassigning the attached equivalences is informally referred to as "recoloring". 12 | * 13 | * `recolor_as` has the same length as `associated_to`, and indicated which 14 | * equivalence should be assigned to which course, respectively. 15 | */ 16 | export type RecolorWarn = { 17 | is_err?: boolean; 18 | kind?: 'recolor'; 19 | associated_to: Array<ClassId>; 20 | recolor_as: Array<EquivalenceId>; 21 | }; 22 | 23 | -------------------------------------------------------------------------------- /frontend/src/client/models/PlanView.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ValidatablePlan } from './ValidatablePlan'; 6 | 7 | /** 8 | * Detailed, typed view of a plan in the database. 9 | * The only difference between this type and `DbPlan` (ie. the plan schema) is that 10 | * the type of `PlanView.validatable_plan` is `ValidatablePlan`, while the type of 11 | * `Plan.validatable_plan` is `Json`. 12 | */ 13 | export type PlanView = { 14 | id: string; 15 | created_at: string; 16 | updated_at: string; 17 | name: string; 18 | is_favorite: boolean; 19 | /** 20 | * A RUT, like 12345678-K. No dots, no leading zeroes, uppercase K. 21 | */ 22 | user_rut: string; 23 | validatable_plan: ValidatablePlan; 24 | }; 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Petición de Funcionalidad 🚀 2 | description: Quieres pedir que se implemente una nueva funcionalidad o modificar una ya existente 3 | title: '[Feat]: ' 4 | labels: ['enhancement'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Muchas gracias por tomarte el tiempo de aportar al proyecto :heart: 10 | 11 | Antes de subir la issue, por favor, revisa las issues pineadas e intenta 12 | buscar si tu idea no ha sido pedida en otra issue. 13 | 14 | - type: textarea 15 | id: descripcion 16 | attributes: 17 | label: Descripción 18 | description: Describe la funcionalidad que quieres que se implemente (o la que quieres que se modifique) con el mayor detalle posible. 19 | validations: 20 | required: true 21 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ESNext" 9 | ], 10 | "allowJs": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": false, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "types": ["vite/client", "vite-plugin-svgr/client"] 23 | }, 24 | "include": [ 25 | "src", 26 | ], 27 | "references": [ 28 | { 29 | "path": "./tsconfig.node.json" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230126033738_add_siding_cache_to_db/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Curriculum" ( 3 | "cyear" TEXT NOT NULL, 4 | "major" TEXT NOT NULL, 5 | "minor" TEXT NOT NULL, 6 | "title" TEXT NOT NULL, 7 | "curriculum" JSONB NOT NULL 8 | ); 9 | 10 | -- CreateTable 11 | CREATE TABLE "PlanRecommendation" ( 12 | "cyear" TEXT NOT NULL, 13 | "major" TEXT NOT NULL, 14 | "minor" TEXT NOT NULL, 15 | "title" TEXT NOT NULL, 16 | "recommended_plan" JSONB NOT NULL 17 | ); 18 | 19 | -- CreateIndex 20 | CREATE UNIQUE INDEX "Curriculum_cyear_major_minor_title_key" ON "Curriculum"("cyear", "major", "minor", "title"); 21 | 22 | -- CreateIndex 23 | CREATE UNIQUE INDEX "PlanRecommendation_cyear_major_minor_title_key" ON "PlanRecommendation"("cyear", "major", "minor", "title"); 24 | -------------------------------------------------------------------------------- /frontend/src/assets/currentWhite.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="12" height="12"><path d="M17,10.039c-3.859,0-7,3.14-7,7,0,3.838,3.141,6.961,7,6.961s7-3.14,7-7c0-3.838-3.141-6.961-7-6.961Zm0,11.961c-2.757,0-5-2.226-5-4.961,0-2.757,2.243-5,5-5s5,2.226,5,4.961c0,2.757-2.243,5-5,5Zm1.707-4.707c.391,.391,.391,1.023,0,1.414-.195,.195-.451,.293-.707,.293s-.512-.098-.707-.293l-1-1c-.188-.188-.293-.442-.293-.707v-2c0-.552,.447-1,1-1s1,.448,1,1v1.586l.707,.707Zm5.293-10.293v2c0,.552-.447,1-1,1s-1-.448-1-1v-2c0-1.654-1.346-3-3-3H5c-1.654,0-3,1.346-3,3v1H11c.552,0,1,.448,1,1s-.448,1-1,1H2v9c0,1.654,1.346,3,3,3h4c.552,0,1,.448,1,1s-.448,1-1,1H5c-2.757,0-5-2.243-5-5V7C0,4.243,2.243,2,5,2h1V1c0-.552,.448-1,1-1s1,.448,1,1v1h8V1c0-.552,.447-1,1-1s1,.448,1,1v1h1c2.757,0,5,2.243,5,5Z" fill="#ffffff"/></svg> -------------------------------------------------------------------------------- /frontend/src/client/core/ApiError.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import type { ApiRequestOptions } from './ApiRequestOptions'; 5 | import type { ApiResult } from './ApiResult'; 6 | 7 | export class ApiError extends Error { 8 | public readonly url: string; 9 | public readonly status: number; 10 | public readonly statusText: string; 11 | public readonly body: any; 12 | public readonly request: ApiRequestOptions; 13 | 14 | constructor(request: ApiRequestOptions, response: ApiResult, message: string) { 15 | super(message); 16 | 17 | this.name = 'ApiError'; 18 | this.url = response.url; 19 | this.status = response.status; 20 | this.statusText = response.statusText; 21 | this.body = response.body; 22 | this.request = request; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/cas.md: -------------------------------------------------------------------------------- 1 | # Uso del servicio de login del backend 2 | 3 | ## CAS 4 | 5 | Es necesario correr un servidor CAS para usar la funcionalidad de autenticación del backend. 6 | El `.env` indica el URL del servidor de CAS, que en el entorno de desarrollo es `localhost:3004`. 7 | Luego, hay que correr un servidor de CAS en `localhost:3004`. 8 | Esto se puede hacer corriendo el siguiente comando *dentro del container* (esto es importante para que el servidor de backend tenga acceso al servidor CAS): 9 | 10 | npx --yes cas-server-mock --database=$PWD/cas-mock/data/cas-mock-users.json 11 | 12 | Notar que el servidor mock CAS es *muy básico*. 13 | Por ejemplo, introducir un usuario inexistente produce una excepción en el servidor de mock CAS, 14 | cortando la conexión al backend, produciendo un error 502. 15 | 16 | Esto se hace automaticamente al correr el proyecto desde VSCode. 17 | -------------------------------------------------------------------------------- /frontend/src/assets/currentBlack.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="12" height="12"><path d="M17,10.039c-3.859,0-7,3.14-7,7,0,3.838,3.141,6.961,7,6.961s7-3.14,7-7c0-3.838-3.141-6.961-7-6.961Zm0,11.961c-2.757,0-5-2.226-5-4.961,0-2.757,2.243-5,5-5s5,2.226,5,4.961c0,2.757-2.243,5-5,5Zm1.707-4.707c.391,.391,.391,1.023,0,1.414-.195,.195-.451,.293-.707,.293s-.512-.098-.707-.293l-1-1c-.188-.188-.293-.442-.293-.707v-2c0-.552,.447-1,1-1s1,.448,1,1v1.586l.707,.707Zm5.293-10.293v2c0,.552-.447,1-1,1s-1-.448-1-1v-2c0-1.654-1.346-3-3-3H5c-1.654,0-3,1.346-3,3v1H11c.552,0,1,.448,1,1s-.448,1-1,1H2v9c0,1.654,1.346,3,3,3h4c.552,0,1,.448,1,1s-.448,1-1,1H5c-2.757,0-5-2.243-5-5V7C0,4.243,2.243,2,5,2h1V1c0-.552,.448-1,1-1s1,.448,1,1v1h8V1c0-.552,.447-1,1-1s1,.448,1,1v1h1c2.757,0,5,2.243,5,5Z"/></svg> 3 | -------------------------------------------------------------------------------- /frontend/src/layout/Banner.tsx: -------------------------------------------------------------------------------- 1 | const Banner = (): JSX.Element => { 2 | return ( 3 | <div className="flex py-2 px-4 border text-yellow-700 border-yellow-300 bg-yellow-50 font-semibold"> 4 | <p> 5 | <svg aria-hidden="true" className="mr-2 flex-shrink-0 inline-flex w-5 h-5 m-auto align-text-bottom" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd"></path></svg> 6 | Le informamos que Mallas ING no estará disponible el INSERTAR_FECHA entre las INSERTAR_HORA_INICIO y las INSERTAR_HORA_FIN hrs debido a una mantención programada en nuestros servidores. Agradecemos su comprensión.</p> 7 | </div> 8 | ) 9 | } 10 | 11 | export default Banner 12 | -------------------------------------------------------------------------------- /frontend/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './src/**/*.{js,jsx,ts,tsx}' 5 | ], 6 | safelist: [ 7 | 'bg-block-PC', 8 | 'bg-place-holder', 9 | 'bg-block-T', 10 | 'bg-block-M', 11 | 'bg-block-m', 12 | 'bg-block-FG', 13 | 'bg-block-' 14 | ], 15 | theme: { 16 | extend: { 17 | colors: { 18 | 'block-PC': 'var(--blockPC)', 19 | 'place-holder': 'var(--placeHolder)', 20 | 'block-T': 'var(--titulo)', 21 | 'block-M': 'var(--major)', 22 | 'block-m': 'var(--minor)', 23 | 'block-FG': 'var(--blockFG)', 24 | 'block-': 'var(--otro)' 25 | } 26 | } 27 | }, 28 | plugins: [ 29 | require('@tailwindcss/forms'), 30 | require('@headlessui/tailwindcss'), 31 | require('@tailwindcss/typography') 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/client/models/StudentInfo.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ConcreteId } from './ConcreteId'; 6 | import type { EquivalenceId } from './EquivalenceId'; 7 | 8 | export type StudentInfo = { 9 | full_name: string; 10 | cyear: string; 11 | is_cyear_supported: boolean; 12 | /** 13 | * A major code, eg. `M072` for hydraulic engineering. 14 | */ 15 | reported_major?: string; 16 | /** 17 | * A minor code, eg. `N204` for numerical analysis. 18 | */ 19 | reported_minor?: string; 20 | /** 21 | * A title code, eg. `40007` for a computer engineering. 22 | */ 23 | reported_title?: string; 24 | passed_courses: Array<Array<(ConcreteId | EquivalenceId)>>; 25 | current_semester: number; 26 | next_semester: number; 27 | admission?: Array<any>; 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /frontend/src/contexts/confetti.context.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import JSConfetti from 'js-confetti' 3 | 4 | // Confetti context to pass the confetti instance to the components 5 | export const ConfettiContext = React.createContext<JSConfetti | null>(null) 6 | 7 | // Confetti provider to initialize the confetti instance 8 | export function ConfettiProvider ({ children }: { children: React.ReactNode }): JSX.Element { 9 | const [confetti, setConfetti] = useState<JSConfetti | null>(null) 10 | 11 | useEffect(() => { 12 | const confettiInstance = new JSConfetti() 13 | setConfetti(confettiInstance) 14 | }, []) 15 | 16 | return ( 17 | <ConfettiContext.Provider value={confetti}> 18 | {children} 19 | </ConfettiContext.Provider> 20 | ) 21 | } 22 | 23 | export const useConfetti = (): JSConfetti | null => React.useContext(ConfettiContext) 24 | -------------------------------------------------------------------------------- /frontend/src/assets/plus.svg: -------------------------------------------------------------------------------- 1 | <svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M10.3333 1.375H7.10833C5.10148 1.375 4.09805 1.375 3.33153 1.76556C2.65729 2.10911 2.10911 2.65729 1.76556 3.33153C1.375 4.09805 1.375 5.10148 1.375 7.10833V10.3333M10.3333 33.625H7.10833C5.10148 33.625 4.09805 33.625 3.33153 33.2344C2.65729 32.8909 2.10911 32.3427 1.76556 31.6685C1.375 30.902 1.375 29.8985 1.375 27.8917V24.6667M33.625 10.3333V7.10833C33.625 5.10148 33.625 4.09805 33.2344 3.33153C32.8909 2.65729 32.3427 2.10911 31.6685 1.76556C30.9019 1.375 29.8985 1.375 27.8917 1.375H24.6667M33.625 24.6667V27.8917C33.625 29.8985 33.625 30.902 33.2344 31.6685C32.8909 32.3427 32.3427 32.8909 31.6685 33.2344C30.902 33.625 29.8985 33.625 27.8917 33.625H24.6667M17.5 26.4583L17.5 8.54167M8.54167 17.5H26.4583" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /frontend/src/assets/calendarWhite.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="12" height="12"><path d="M17.5,11.039c-3.584,0-6.5,2.916-6.5,6.5,0,3.563,2.916,6.461,6.5,6.461s6.5-2.916,6.5-6.5c0-3.563-2.916-6.461-6.5-6.461Zm0,11.961c-3.032,0-5.5-2.45-5.5-5.461,0-3.033,2.468-5.5,5.5-5.5s5.5,2.45,5.5,5.461c0,3.033-2.468,5.5-5.5,5.5Zm1.354-4.854c.195,.195,.195,.512,0,.707-.098,.098-.226,.146-.354,.146s-.256-.049-.354-.146l-1-1c-.094-.094-.146-.221-.146-.354v-2c0-.276,.224-.5,.5-.5s.5,.224,.5,.5v1.793l.854,.854Zm.646-16.146h-1.5V.5c0-.276-.224-.5-.5-.5s-.5,.224-.5,.5v1.5H7V.5c0-.276-.224-.5-.5-.5s-.5,.224-.5,.5v1.5h-1.5C2.019,2,0,4.019,0,6.5v13c0,2.481,2.019,4.5,4.5,4.5h6c.276,0,.5-.224,.5-.5s-.224-.5-.5-.5H4.5c-1.93,0-3.5-1.57-3.5-3.5V9H23v1.5c0,.276,.224,.5,.5,.5s.5-.224,.5-.5V6.5c0-2.481-2.019-4.5-4.5-4.5ZM1,8v-1.5c0-1.93,1.57-3.5,3.5-3.5h15c1.93,0,3.5,1.57,3.5,3.5v1.5H1Z" fill="#ffffff"/></svg> -------------------------------------------------------------------------------- /frontend/src/client/models/CurriculumSpec.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Represents a curriculum specification. 7 | * This specification should uniquely identify a curriculum, although it contains no 8 | * information about the curriculum itself. 9 | * 10 | * NOTE: Remember to reset the cache in the database after any changes, either manually 11 | * or through migrations. 12 | */ 13 | export type CurriculumSpec = { 14 | /** 15 | * The curriculum version. 16 | */ 17 | cyear: ('C2020' | 'C2022'); 18 | /** 19 | * A major code, eg. `M072` for hydraulic engineering. 20 | */ 21 | major?: string; 22 | /** 23 | * A minor code, eg. `N204` for numerical analysis. 24 | */ 25 | minor?: string; 26 | /** 27 | * A title code, eg. `40007` for a computer engineering. 28 | */ 29 | title?: string; 30 | }; 31 | 32 | -------------------------------------------------------------------------------- /frontend/src/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from '@tanstack/react-router' 2 | import Footer from './Footer' 3 | import Navbar from './Navbar' 4 | import { ToastContainer } from 'react-toastify' 5 | // import Banner from './Banner' 6 | 7 | function Layout (): JSX.Element { 8 | return ( 9 | <div className="flex flex-col overflow-hidden"> 10 | <div className="flex flex-col overflow-hidden h-screen"> 11 | <Navbar /> 12 | <ToastContainer 13 | position="top-center" 14 | autoClose={2500} 15 | hideProgressBar={false} 16 | newestOnTop={false} 17 | closeOnClick={true} 18 | draggable={false} 19 | pauseOnFocusLoss={false} 20 | pauseOnHover={false} 21 | rtl={false} 22 | theme="light" 23 | /> 24 | {/* <Banner /> */} 25 | <Outlet /> 26 | </div> 27 | <hr/> 28 | <Footer /> 29 | </div> 30 | ) 31 | } 32 | 33 | export default Layout 34 | -------------------------------------------------------------------------------- /backend/typings/cas/orjson/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import json 6 | from typing import Any, Callable, Optional, Union 7 | 8 | __version__: str 9 | def dumps(__obj: Any, default: Optional[Callable[[Any], Any]] = ..., option: Optional[int] = ...) -> bytes: 10 | ... 11 | 12 | def loads(__obj: Union[bytes, bytearray, memoryview, str]) -> Any: 13 | ... 14 | 15 | class JSONDecodeError(json.JSONDecodeError): 16 | ... 17 | 18 | 19 | class JSONEncodeError(TypeError): 20 | ... 21 | 22 | 23 | OPT_APPEND_NEWLINE: int 24 | OPT_INDENT_2: int 25 | OPT_NAIVE_UTC: int 26 | OPT_NON_STR_KEYS: int 27 | OPT_OMIT_MICROSECONDS: int 28 | OPT_PASSTHROUGH_DATACLASS: int 29 | OPT_PASSTHROUGH_DATETIME: int 30 | OPT_PASSTHROUGH_SUBCLASS: int 31 | OPT_SERIALIZE_DATACLASS: int 32 | OPT_SERIALIZE_NUMPY: int 33 | OPT_SERIALIZE_UUID: int 34 | OPT_SORT_KEYS: int 35 | OPT_STRICT_INTEGER: int 36 | OPT_UTC_Z: int 37 | -------------------------------------------------------------------------------- /frontend/src/assets/calendarBlack.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="12" height="12"><path d="M17.5,11.039c-3.584,0-6.5,2.916-6.5,6.5,0,3.563,2.916,6.461,6.5,6.461s6.5-2.916,6.5-6.5c0-3.563-2.916-6.461-6.5-6.461Zm0,11.961c-3.032,0-5.5-2.45-5.5-5.461,0-3.033,2.468-5.5,5.5-5.5s5.5,2.45,5.5,5.461c0,3.033-2.468,5.5-5.5,5.5Zm1.354-4.854c.195,.195,.195,.512,0,.707-.098,.098-.226,.146-.354,.146s-.256-.049-.354-.146l-1-1c-.094-.094-.146-.221-.146-.354v-2c0-.276,.224-.5,.5-.5s.5,.224,.5,.5v1.793l.854,.854Zm.646-16.146h-1.5V.5c0-.276-.224-.5-.5-.5s-.5,.224-.5,.5v1.5H7V.5c0-.276-.224-.5-.5-.5s-.5,.224-.5,.5v1.5h-1.5C2.019,2,0,4.019,0,6.5v13c0,2.481,2.019,4.5,4.5,4.5h6c.276,0,.5-.224,.5-.5s-.224-.5-.5-.5H4.5c-1.93,0-3.5-1.57-3.5-3.5V9H23v1.5c0,.276,.224,.5,.5,.5s.5-.224,.5-.5V6.5c0-2.481-2.019-4.5-4.5-4.5ZM1,8v-1.5c0-1.93,1.57-3.5,3.5-3.5h15c1.93,0,3.5,1.57,3.5,3.5v1.5H1Z"/></svg> 3 | -------------------------------------------------------------------------------- /frontend/src/components/Banner.tsx: -------------------------------------------------------------------------------- 1 | 2 | interface BannerProps { 3 | bannerType: 'Warning' | 'Error' 4 | text: string 5 | } 6 | 7 | const Banner = ({ bannerType, text }: BannerProps): JSX.Element => { 8 | return ( 9 | <div className={`flex py-2 px-4 border 10 | ${bannerType === 'Warning' ? 'text-yellow-700 border-yellow-300 bg-yellow-50' : ''} 11 | ${bannerType === 'Error' ? 'text-red-800 border-red-300 bg-red-50' : ''} 12 | font-semibold`}> 13 | <p> 14 | <svg aria-hidden="true" className="mr-2 flex-shrink-0 inline-flex w-5 h-5 m-auto align-text-bottom" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd"></path></svg> 15 | {text}</p> 16 | </div> 17 | ) 18 | } 19 | 20 | export default Banner 21 | -------------------------------------------------------------------------------- /backend/typings/cas/dns/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import enum 6 | 7 | class IntEnum(enum.IntEnum): 8 | @classmethod 9 | def from_text(cls, text): # -> IntEnum | Self@IntEnum | int: 10 | ... 11 | 12 | @classmethod 13 | def to_text(cls, value): # -> str: 14 | ... 15 | 16 | @classmethod 17 | def make(cls, value): # -> IntEnum | Self@IntEnum | int: 18 | """Convert text or a value into an enumerated type, if possible. 19 | 20 | *value*, the ``int`` or ``str`` to convert. 21 | 22 | Raises a class-specific exception if a ``str`` is provided that 23 | cannot be converted. 24 | 25 | Raises ``ValueError`` if the value is out of range. 26 | 27 | Returns an enumeration from the calling class corresponding to the 28 | value, if one is defined, or an ``int`` otherwise. 29 | """ 30 | ... 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /frontend/conf/Caddyfile: -------------------------------------------------------------------------------- 1 | {$PLANNER_URL} { 2 | handle /* { 3 | root * /srv 4 | file_server 5 | # This acts as the index file (SPA) 6 | try_files {path} /index.html 7 | # Enable compression 8 | encode zstd gzip 9 | # Normal files are cached for an hour 10 | header Cache-Control "public,max-age=3600" 11 | # Assets have a version tag, so they're immutable 12 | # they're cached for a year 13 | header /assets/* Cache-Control "public,max-age=31536000,immutable" 14 | } 15 | # The handle_path performs path rewriting 16 | # to strip the /api prefix 17 | handle_path /api/* { 18 | reverse_proxy api:80 19 | } 20 | 21 | # Security headers 22 | header { 23 | X-Content-Type-Options nosniff 24 | Referrer-Policy no-referrer-when-downgrade 25 | X-Frame-Options DENY 26 | Content-Security-Policy "frame-ancestors 'none'" 27 | } 28 | 29 | 30 | # Health check 31 | respond /health 200 "OK" 32 | 33 | # Teapot 34 | respond /teapot 418 "I'm a teapot" 35 | 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/client/models/ValidatablePlan.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ConcreteId } from './ConcreteId'; 6 | import type { CurriculumSpec } from './CurriculumSpec'; 7 | import type { EquivalenceId } from './EquivalenceId'; 8 | 9 | /** 10 | * An academic plan submitted by a user. 11 | * Contains all of the courses they have passed and intend to pass. 12 | * Also contains all of the context associated with the user (e.g. their choice of 13 | * major and minor). 14 | * 15 | * Including user context here allows plans to be validated without external context, 16 | * allowing guests to simulate any plans they want to try out. 17 | */ 18 | export type ValidatablePlan = { 19 | version: '0.0.2'; 20 | classes: Array<Array<(ConcreteId | EquivalenceId)>>; 21 | level?: string; 22 | school?: string; 23 | program?: string; 24 | career?: string; 25 | curriculum: CurriculumSpec; 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /backend/typings/cas/starlette/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import sys 6 | import typing 7 | from typing import ParamSpec 8 | from starlette._utils import is_async_callable 9 | from starlette.concurrency import run_in_threadpool 10 | 11 | if sys.version_info >= (3, 10): 12 | ... 13 | else: 14 | ... 15 | P = ParamSpec("P") 16 | class BackgroundTask: 17 | def __init__(self, func: typing.Callable[P, typing.Any], *args: P.args, **kwargs: P.kwargs) -> None: 18 | ... 19 | 20 | async def __call__(self) -> None: 21 | ... 22 | 23 | 24 | 25 | class BackgroundTasks(BackgroundTask): 26 | def __init__(self, tasks: typing.Optional[typing.Sequence[BackgroundTask]] = ...) -> None: 27 | ... 28 | 29 | def add_task(self, func: typing.Callable[P, typing.Any], *args: P.args, **kwargs: P.kwargs) -> None: 30 | ... 31 | 32 | async def __call__(self) -> None: 33 | ... 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /backend/app/redis.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from typing import Any 3 | 4 | from app.settings import settings 5 | from redis.asyncio import ConnectionPool, Redis 6 | 7 | 8 | def init_redis_pool() -> ConnectionPool: # type: ignore 9 | return ConnectionPool.from_url( # type: ignore 10 | settings.redis_uri, 11 | decode_responses=True, 12 | encoding="utf-8", 13 | ) 14 | 15 | 16 | connection_pool = init_redis_pool() # type: ignore 17 | 18 | 19 | @contextlib.asynccontextmanager 20 | async def get_redis(): 21 | """ 22 | Get a Redis connection from the pool. 23 | 24 | Use with `async with` in order to close the connection at the end of the scope. 25 | """ 26 | redis: Redis[Any] = Redis( 27 | connection_pool=connection_pool, # type: ignore 28 | auto_close_connection_pool=False, 29 | decode_responses=True, 30 | encoding="utf-8", 31 | ) 32 | try: 33 | yield redis 34 | finally: 35 | await redis.close() 36 | -------------------------------------------------------------------------------- /frontend/src/client/models/CourseDetails.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { And } from './And'; 6 | import type { Const } from './Const'; 7 | import type { MinCredits } from './MinCredits'; 8 | import type { Or } from './Or'; 9 | import type { ReqCareer } from './ReqCareer'; 10 | import type { ReqCourse } from './ReqCourse'; 11 | import type { ReqLevel } from './ReqLevel'; 12 | import type { ReqProgram } from './ReqProgram'; 13 | import type { ReqSchool } from './ReqSchool'; 14 | 15 | export type CourseDetails = { 16 | code: string; 17 | name: string; 18 | credits: number; 19 | deps: (And | Or | Const | MinCredits | ReqLevel | ReqSchool | ReqProgram | ReqCareer | ReqCourse); 20 | banner_equivs: Array<string>; 21 | canonical_equiv: string; 22 | program: string; 23 | school: string; 24 | area?: string; 25 | category?: string; 26 | is_available: boolean; 27 | semestrality: Array<any>; 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true 5 | }, 6 | extends: [ 7 | 'plugin:react/recommended', 8 | 'plugin:react/jsx-runtime', 9 | 'standard-with-typescript', 10 | 'plugin:jsx-a11y/recommended', 11 | 'plugin:react-hooks/recommended' 12 | ], 13 | ignorePatterns: [ 14 | 'vite*.ts' 15 | ], 16 | overrides: [ 17 | ], 18 | parserOptions: { 19 | ecmaVersion: 'latest', 20 | sourceType: 'module', 21 | tsconfigRootDir: __dirname, 22 | project: './tsconfig.json' 23 | }, 24 | plugins: [ 25 | 'react' 26 | ], 27 | rules: { 28 | '@typescript-eslint/ban-types': [ 29 | 'error', 30 | { 31 | types: { 32 | Function: false 33 | }, 34 | extendDefaults: true 35 | } 36 | ], 37 | '@typescript-eslint/ban-tslint-comment': 'off', 38 | 'react/prop-types': 'off' 39 | }, 40 | settings: { 41 | react: { 42 | version: 'detect' 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/client/core/OpenAPI.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | import type { ApiRequestOptions } from './ApiRequestOptions'; 5 | 6 | type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; 7 | type Headers = Record<string, string>; 8 | 9 | export type OpenAPIConfig = { 10 | BASE: string; 11 | VERSION: string; 12 | WITH_CREDENTIALS: boolean; 13 | CREDENTIALS: 'include' | 'omit' | 'same-origin'; 14 | TOKEN?: string | Resolver<string>; 15 | USERNAME?: string | Resolver<string>; 16 | PASSWORD?: string | Resolver<string>; 17 | HEADERS?: Headers | Resolver<Headers>; 18 | ENCODE_PATH?: (path: string) => string; 19 | }; 20 | 21 | export const OpenAPI: OpenAPIConfig = { 22 | BASE: '/api', 23 | VERSION: '0.1.0', 24 | WITH_CREDENTIALS: false, 25 | CREDENTIALS: 'include', 26 | TOKEN: undefined, 27 | USERNAME: undefined, 28 | PASSWORD: undefined, 29 | HEADERS: undefined, 30 | ENCODE_PATH: undefined, 31 | }; 32 | -------------------------------------------------------------------------------- /frontend/src/layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | function Footer (): JSX.Element { 2 | return (<footer className="mt-3"> 3 | 4 | <p className="mb-2 text-gray-600 text-center text-sm">Desarrollado por el <a className={'text-blue-700 underline text-sm'} href="https://github.com/open-source-uc/planner/tree/dev#equipo" target="_blank" rel="noreferrer">Equipo Planner</a>📚 con la ayuda de <a className={'text-blue-700 underline text-sm'} href="https://github.com/open-source-uc/planner/blob/dev/contributors.md" target="_blank" rel="noreferrer"> otros contribuidores</a></p> 5 | 6 | <p className="mb-3 text-gray-600 text-center text-sm">Este proyecto se encuentra bajo la licencia <a className={'text-blue-700 underline text-sm'} href="https://github.com/open-source-uc/planner/blob/dev/license.md" target="_blank" rel="noreferrer">AGPLv3</a> y es <a className={'text-blue-700 underline text-sm'} href="https://github.com/open-source-uc/planner" target="_blank" rel="noreferrer">código abierto</a>🚀</p> 7 | </footer>) 8 | } 9 | 10 | export default Footer 11 | -------------------------------------------------------------------------------- /frontend/src/components/Info.tsx: -------------------------------------------------------------------------------- 1 | interface InfoProps { 2 | message?: string 3 | } 4 | 5 | export function Info ({ message }: InfoProps): JSX.Element { 6 | return <div className="group relative flex justify-center"> 7 | <svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"> 8 | <circle cx="11" cy="11" r="10" stroke="black" strokeOpacity="0.5" strokeWidth="2"/> 9 | <line x1="11" y1="9" x2="11" y2="17" stroke="black" strokeOpacity="0.5" strokeWidth="2"/> 10 | <line x1="12" y1="6" x2="10" y2="6" stroke="black" strokeOpacity="0.5" strokeWidth="2"/> 11 | </svg> 12 | {message !== undefined && <span className={'fixed z-10 ml-2 transition-all scale-0 group-hover:scale-100 w-fit'}> 13 | <div className="absolute left-2.5 top-1 w-4 h-4 bg-gray-800 rotate-45 rounded" /> 14 | <span className={'absolute left-4 -top-1 max-w-[14rem] z-10 rounded bg-gray-800 p-2 text-xs text-white w-max'}> 15 | {message} 16 | </span> 17 | </span> 18 | } 19 | </div> 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/pages/planner/ControlTopBar.tsx: -------------------------------------------------------------------------------- 1 | interface ControlTopBarProps { 2 | isMod: boolean 3 | reset: Function 4 | openLegendModal: Function 5 | openSavePlanModal: Function 6 | } 7 | 8 | function ControlTopBar ({ isMod, reset, openLegendModal, openSavePlanModal }: ControlTopBarProps): JSX.Element { 9 | return ( 10 | <ul className="flex items-center ml-3 mb-1 gap-6"> 11 | {!isMod && (<> 12 | <li className='inline'><button onClick={() => openSavePlanModal() }>Guardar malla</button></li> 13 | </>)} 14 | <li className='inline'><button onClick={() => reset()}>Restablecer malla</button></li> 15 | {/* <li className="inline opacity-50 cursor-not-allowed">Exportar malla</li> */} 16 | <li className="inline"><button onClick={() => openLegendModal()}>Ver leyenda</button></li> 17 | <li className="inline"><a href="https://github.com/open-source-uc/planner/issues?q=is%3Aopen+is%3Aissue+label%3Abug" rel="noreferrer" target="_blank">Reportar errores</a></li> 18 | </ul>) 19 | } 20 | 21 | export default ControlTopBar 22 | -------------------------------------------------------------------------------- /run_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Notas sobre este archivo: 4 | # 1. Este archivo solamente debe ser usado para generar despliegues del planner en la máquina de producción. 5 | # 2. NO se debe usar este archivo en ambientes de desarrollo. 6 | # 3. Este archivo puede ser modificado, y debería actualizarse automáticamente en la máquina de producción. 7 | 8 | # Instalar Ansible si no está presente 9 | if ! command -v ansible >/dev/null; then 10 | echo "Ansible no está instalado. Instalando Ansible..." 11 | sudo dnf install epel-release 12 | sudo dnf install ansible -y 13 | fi 14 | 15 | # Instalar el módulo community.docker si no está presente 16 | if ! ansible-galaxy collection list | grep -q 'community.docker'; then 17 | echo "Instalando el módulo community.docker para Ansible..." 18 | ansible-galaxy collection install community.docker 19 | fi 20 | 21 | # Definir el directorio del playbook 22 | PLAYBOOK_DIR=/opt/planner/infra 23 | 24 | # Ejecutar el playbook de Ansible 25 | echo "Ejecutando el playbook de Ansible..." 26 | ansible-playbook "$PLAYBOOK_DIR/playbook.yml" 27 | -------------------------------------------------------------------------------- /backend/app/sync/curriculums/scrape/common.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from app.plan.validation.curriculum.tree import MajorCode, MinorCode, TitleCode 4 | 5 | 6 | class ScrapedBlock(BaseModel): 7 | """ 8 | - name: Name of the block. Might not be available for all blocks. 9 | - creds: Amount of credits that this block weights. Might not be available if the 10 | block consists of a single course. 11 | - options: The course codes that this block admits. 12 | - complementary: Whether the block is a complementary minor/title course or not. 13 | - nonexclusive: The opposite of a complementary block. It's required that these 14 | courses are taken, but they may count towards another block. 15 | """ 16 | 17 | name: str | None 18 | creds: int | None 19 | options: list[str] 20 | complementary: bool 21 | nonexclusive: bool 22 | 23 | 24 | class ScrapedProgram(BaseModel): 25 | code: str 26 | assoc_major: MajorCode | None 27 | assoc_minor: MinorCode | None 28 | assoc_title: TitleCode | None 29 | blocks: list[ScrapedBlock] 30 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:3.11 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | # [Optional] If your requirements rarely change, uncomment this section to add them to the image. 6 | # COPY requirements.txt /tmp/pip-tmp/ 7 | # RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ 8 | # && rm -rf /tmp/pip-tmp 9 | 10 | ARG NODE_VERSION="16" 11 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 12 | 13 | # Install Just 14 | RUN curl --proto '=https' --tlsv1.3 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin/ 15 | RUN chmod +x /usr/local/bin/just 16 | 17 | # [Optional] Uncomment this section to install additional OS packages. 18 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 19 | && apt-get -y install --no-install-recommends postgresql-client watchman libonig-dev redis-tools 20 | 21 | # Install HTTPie via pip 22 | RUN python -m pip install --upgrade pip wheel 23 | RUN python -m pip install httpie 24 | -------------------------------------------------------------------------------- /backend/typings/cas/prisma/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import json 6 | import httpx 7 | from typing import Any 8 | from ._types import Method 9 | from .http_abstract import AbstractHTTP, AbstractResponse 10 | 11 | __all__ = ('HTTP', 'Response', 'client') 12 | class HTTP(AbstractHTTP[httpx.AsyncClient, httpx.Response]): 13 | session: httpx.AsyncClient 14 | __slots__ = ... 15 | async def download(self, url: str, dest: str) -> None: 16 | ... 17 | 18 | async def request(self, method: Method, url: str, **kwargs: Any) -> Response: 19 | ... 20 | 21 | def open(self) -> None: 22 | ... 23 | 24 | async def close(self) -> None: 25 | ... 26 | 27 | 28 | 29 | client: HTTP = ... 30 | class Response(AbstractResponse[httpx.Response]): 31 | __slots__ = ... 32 | @property 33 | def status(self) -> int: 34 | ... 35 | 36 | async def json(self, **kwargs: Any) -> Any: 37 | ... 38 | 39 | async def text(self, **kwargs: Any) -> str: 40 | ... 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/src/client/models/CurriculumErr.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ClassId } from './ClassId'; 6 | import type { ConcreteId } from './ConcreteId'; 7 | import type { EquivalenceId } from './EquivalenceId'; 8 | 9 | /** 10 | * Indicates that there are some courses missing to fulfill the chosen curriculum. 11 | * The incomplete block is given in `block`, and the amount of credits missing in 12 | * `credits`. 13 | * A set of courses that would fill this block (possibly equivalences) is given in 14 | * `recommend`. 15 | * Because equivalences could be potentially unknown to the frontend and we don't want 16 | * to show the user equivalence codes, each course is coupled with its name. 17 | */ 18 | export type CurriculumErr = { 19 | is_err?: boolean; 20 | kind?: 'curr'; 21 | associated_to?: null; 22 | blocks: Array<Array<string>>; 23 | credits: number; 24 | fill_options: Array<(ConcreteId | EquivalenceId)>; 25 | panacea_recolor_courses?: Array<ClassId>; 26 | panacea_recolor_blocks?: Array<EquivalenceId>; 27 | }; 28 | 29 | -------------------------------------------------------------------------------- /backend/app/plan/validation/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Validation of a career plan. 3 | 4 | A career plan is simply a list of semesters, every semester consisting of a list of 5 | classes. 6 | Some semesters are considered approved and are not validated, while other semesters are 7 | considered planned and have to be validated. 8 | 9 | Overall validation can be divided into two separate validation passes: 10 | - Course validation: Make sure each individual planned course has their requirements 11 | met. Approved classes are not validated in this step. This is a logical validation, 12 | and is modelled as a logical expression for each class that must be satisfied. 13 | - Curriculum validation: Make sure the set of approved and planned courses satisfy the 14 | selected curriculum. 15 | In real life, this validation is not completely specified, as each student only 16 | gets their career validated once when they graduate, and this validation is done 17 | semi-manually. 18 | We probably want to model this as a flow network, where each course provides 19 | credits and the curriculum consumes credits. 20 | """ 21 | -------------------------------------------------------------------------------- /frontend/src/pages/planner/dialogs/SavePlanModal.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect, useState } from 'react' 2 | import TextInputModal from '../../../components/TextInputModal' 3 | 4 | const SavePlanModal = ({ isOpen, onClose, savePlan, defaultValue = '' }: { isOpen: boolean, onClose: Function, savePlan: Function, defaultValue?: string }): JSX.Element => { 5 | const [planName, setPlanName] = useState<string>('') 6 | 7 | const isSaveButtonDisabled: boolean = planName === '' 8 | 9 | useEffect(() => { 10 | setPlanName(defaultValue) 11 | }, [defaultValue]) 12 | 13 | return ( 14 | <TextInputModal 15 | title="Nombre de la planificación" 16 | isOpen={isOpen} 17 | handleAccept={savePlan} 18 | onClose={onClose} 19 | acceptMessage="Guardar" 20 | error={false} 21 | errorMsg="Nombre invalido." 22 | isLoading={false} 23 | isAcceptButtonDisabled={isSaveButtonDisabled} 24 | inputValue={planName} 25 | handleInputChange={(e: React.ChangeEvent<HTMLInputElement>) => { setPlanName(e.target.value) }} 26 | /> 27 | ) 28 | } 29 | 30 | export default memo(SavePlanModal) 31 | -------------------------------------------------------------------------------- /backend/.env.default: -------------------------------------------------------------------------------- 1 | ################################################################################################ 2 | # No es necesario modificar este archivo durante el proceso de deploy. # 3 | # # 4 | # Nota para los desarrolladores: # 5 | # Aquí van las variables de entorno para producción que NO contienen secretos pero requieren # 6 | # ser definidas antes de levantar el contenedor. Si una variable se utiliza en el código, # 7 | # entonces debe ser definida en el archivo `settings.py` en vez de aquí. # 8 | # Para desarrollo, se sobreescriben estas variables en el archivo `.env`. # 9 | ################################################################################################ 10 | 11 | # Variable requerida para configurar Prisma. 12 | DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres?schema=public 13 | 14 | # Variable requerida para definir el ambiente de ejecución. 15 | PYTHON_ENV=production 16 | -------------------------------------------------------------------------------- /infra/productive-deploy/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | # Notas sobre este archivo: 5 | # 1. Este archivo solamente debe ser usado para buscar cambios y actualizar automáticamente la máquina de producción del planner. 6 | # 2. NO se debe usar este archivo en ambientes de desarrollo. 7 | # 3. Ningún cambio a este archivo se verá reflejado en la máquina de producción de forma automática. Para actualizarlo, se debe solicitar el administrador de la máquina que vuelva a copiar el archivo hacia la ubicación en que se utiliza. 8 | 9 | # Definir el directorio del repositorio 10 | REPO_DIR=/opt/planner 11 | 12 | # Moverse al directorio del repositorio 13 | cd "$REPO_DIR" 14 | 15 | # Actualizar la rama main del repositorio 16 | git fetch origin main 17 | 18 | # Obtener el hash del commit local y el remoto 19 | LOCAL=$(git rev-parse HEAD) 20 | REMOTE=$(git rev-parse origin/main) 21 | 22 | # Si se detectan cambios, ejecutar run_deploy.sh 23 | if [ "$LOCAL" != "$REMOTE" ]; then 24 | echo "Changes detected. Running deploy script..." 25 | git restore . 26 | git checkout origin/main 27 | chmod +x ./run_deploy.sh 28 | ./run_deploy.sh 29 | fi 30 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230113031721_add_availability_to_course/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `is_available` to the `Course` table without a default value. This is not possible if the table is not empty. 5 | - Added the required column `is_relevant` to the `Course` table without a default value. This is not possible if the table is not empty. 6 | - Added the required column `semestrality_first` to the `Course` table without a default value. This is not possible if the table is not empty. 7 | - Added the required column `semestrality_second` to the `Course` table without a default value. This is not possible if the table is not empty. 8 | - Added the required column `semestrality_tav` to the `Course` table without a default value. This is not possible if the table is not empty. 9 | 10 | */ 11 | -- AlterTable 12 | ALTER TABLE "Course" ADD COLUMN "is_available" BOOLEAN NOT NULL, 13 | ADD COLUMN "is_relevant" BOOLEAN NOT NULL, 14 | ADD COLUMN "semestrality_first" BOOLEAN NOT NULL, 15 | ADD COLUMN "semestrality_second" BOOLEAN NOT NULL, 16 | ADD COLUMN "semestrality_tav" BOOLEAN NOT NULL; 17 | -------------------------------------------------------------------------------- /frontend/src/client/models/OutdatedPlanErr.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { ConcreteId } from './ConcreteId'; 6 | import type { EquivalenceId } from './EquivalenceId'; 7 | 8 | /** 9 | * Indicates that the plan does not reflect the courses that the user has taken. 10 | * This could happen if the user planned ahead, but didn't follow their plan. 11 | * Afterwards, when they take different courses than they planned, their plan becomes 12 | * outdated. 13 | * The semesters that are mismatched are included in `associated_to`. 14 | * Each mismatched semester contains an associated entry in `replace_with` with the 15 | * courses that should replace this semester. 16 | * If `is_current` is true, then the only outdated semester is the current semester, 17 | * which may be a special case if the user is trying out changes to their current 18 | * semester. 19 | */ 20 | export type OutdatedPlanErr = { 21 | is_err?: boolean; 22 | kind?: 'outdated'; 23 | associated_to: Array<number>; 24 | replace_with: Array<Array<(ConcreteId | EquivalenceId)>>; 25 | is_current: boolean; 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /frontend/src/client/models/EquivDetails.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | /** 6 | * Details about an equivalence. 7 | * - code: Unique code identifying this equivalence. 8 | * Unique across course and equivalence codes (ie. course and equivalence names 9 | * live in the same namespace). 10 | * - name: Informative name of this equivalence. 11 | * - is_homogeneous: Indicates whether this equivalence is "homogeneous". 12 | * A homogeneous equivalence is one where all of its concrete courses have the 13 | * same requirements and reverse requirements (eg. "Dinamica" is homogeneous, but 14 | * "OFG" is not). 15 | * The requirement validator gives up on non-homogeneous equivalences, but tries 16 | * to validate homogeneous dependencies. 17 | * - is_unessential: Whether the equivalence can go unspecified without raising an 18 | * error. 19 | * - courses: List of concrete course codes that make up this equivalence. 20 | */ 21 | export type EquivDetails = { 22 | code: string; 23 | name: string; 24 | is_homogeneous: boolean; 25 | is_unessential: boolean; 26 | courses: Array<string>; 27 | }; 28 | 29 | -------------------------------------------------------------------------------- /frontend/.env.staging.template: -------------------------------------------------------------------------------- 1 | ############################################################################################### 2 | # Este archivo está pensado para ser copiado con el nombre `.env` y ser usado en producción. # 3 | # Es necesario entregar los valores reales a cada una de las variables definidas abajo # 4 | # antes de levantar los contenedores de la aplicación para que funcione correctamente. # 5 | ############################################################################################### 6 | 7 | # URL que apunta al planner para Caddy. 8 | # --> ejemplo: "https://plan.ing.puc.cl" 9 | PLANNER_URL="https://mallastest.ing.uc.cl" 10 | 11 | # URL del servidor de autenticacion CAS. 12 | # --> ejemplo: "https://sso.uc.cl/cas/" 13 | # El slash al final es importante. 14 | VITE_CAS_SERVER_URL=https://sso.uc.cl/cas/ 15 | 16 | # No debería ser necesario modificar esta variable. 17 | VITE_BASE_API_URL="/api" 18 | 19 | # No debería ser necesario modificar esta variable. 20 | BACKEND_API_URL="http://api:8000" 21 | 22 | # Ocultar las opciones para hacer login 23 | # Usado previo a la habilitación del SSO 24 | VITE_HIDE_LOGIN = "false" 25 | 26 | # Don't remove 27 | NODE_ENV="staging" 28 | -------------------------------------------------------------------------------- /backend/app/sync/curriculums/minor.py: -------------------------------------------------------------------------------- 1 | from app.plan.courseinfo import CourseDetails 2 | from app.plan.validation.curriculum.tree import ( 3 | CurriculumSpec, 4 | ) 5 | from app.sync.curriculums.scrape.minor import ScrapedProgram 6 | from app.sync.curriculums.scrape.translate import ProgramType, translate_scrape 7 | from app.sync.curriculums.siding import SidingInfo 8 | from app.sync.curriculums.storage import CurriculumStorage, ProgramDetails 9 | from app.sync.siding.client import BloqueMalla 10 | 11 | MINOR_TYPE = ProgramType( 12 | superblock_id="Minor", 13 | readable_id="Minor", 14 | layer_id="minor", 15 | order_base=100, 16 | exclusive_credits=None, 17 | ) 18 | 19 | 20 | def translate_minor( 21 | courses: dict[str, CourseDetails], 22 | out: CurriculumStorage, 23 | spec: CurriculumSpec, 24 | meta: ProgramDetails, 25 | siding_info: SidingInfo, 26 | siding: list[BloqueMalla], 27 | scrape: ScrapedProgram, 28 | ): 29 | curr = translate_scrape( 30 | MINOR_TYPE, 31 | courses, 32 | out, 33 | spec, 34 | meta.name, 35 | siding_info, 36 | siding, 37 | scrape, 38 | ) 39 | 40 | out.set_minor(spec, curr) 41 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230110025406_add_major_minor_title/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Major" ( 3 | "cyear" TEXT NOT NULL, 4 | "code" TEXT NOT NULL, 5 | "name" TEXT NOT NULL, 6 | "version" TEXT NOT NULL 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "Minor" ( 11 | "cyear" TEXT NOT NULL, 12 | "code" TEXT NOT NULL, 13 | "name" TEXT NOT NULL, 14 | "version" TEXT NOT NULL, 15 | "minor_type" TEXT NOT NULL 16 | ); 17 | 18 | -- CreateTable 19 | CREATE TABLE "MajorMinor" ( 20 | "major" TEXT NOT NULL, 21 | "minor" TEXT NOT NULL 22 | ); 23 | 24 | -- CreateTable 25 | CREATE TABLE "Title" ( 26 | "cyear" TEXT NOT NULL, 27 | "code" TEXT NOT NULL, 28 | "name" TEXT NOT NULL, 29 | "version" TEXT NOT NULL, 30 | "title_type" TEXT NOT NULL 31 | ); 32 | 33 | -- CreateIndex 34 | CREATE UNIQUE INDEX "Major_cyear_code_key" ON "Major"("cyear", "code"); 35 | 36 | -- CreateIndex 37 | CREATE UNIQUE INDEX "Minor_cyear_code_key" ON "Minor"("cyear", "code"); 38 | 39 | -- CreateIndex 40 | CREATE UNIQUE INDEX "MajorMinor_major_minor_key" ON "MajorMinor"("major", "minor"); 41 | 42 | -- CreateIndex 43 | CREATE UNIQUE INDEX "Title_cyear_code_key" ON "Title"("cyear", "code"); 44 | -------------------------------------------------------------------------------- /backend/tests/test_dnf.py: -------------------------------------------------------------------------------- 1 | from app.plan.validation.courses.logic import ( 2 | And, 3 | AndClause, 4 | Atom, 5 | DnfExpr, 6 | Expr, 7 | Or, 8 | ReqCourse, 9 | ) 10 | from app.plan.validation.courses.simplify import as_dnf 11 | 12 | 13 | def test_dnf(): 14 | def atom(code: str) -> ReqCourse: 15 | return ReqCourse(code=code, coreq=False) 16 | 17 | def atoms(num: int) -> list[ReqCourse]: 18 | return [atom(chr(ord("A") + i)) for i in range(num)] 19 | 20 | def o(*children: Expr) -> Or: 21 | return Or(children=tuple(children)) 22 | 23 | def y(*children: Expr) -> And: 24 | return And(children=tuple(children)) 25 | 26 | def dnf(*andclauses: list[Atom]): 27 | return DnfExpr( 28 | children=tuple( 29 | AndClause(children=tuple(andclause)) for andclause in andclauses 30 | ), 31 | ) 32 | 33 | a, b, c, d = atoms(4) 34 | 35 | assert as_dnf( 36 | y(a, b, o(c, d)), 37 | ) == dnf([a, b, c], [a, b, d]) 38 | assert as_dnf(o(y(), a)) == dnf([], [a]) 39 | assert as_dnf(o(o(c, d), a, b)) == dnf([c], [d], [a], [b]) 40 | assert as_dnf(o()) == dnf() 41 | assert as_dnf(y(b, c)) == dnf([b, c]) 42 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite' 2 | import { sentryVitePlugin } from "@sentry/vite-plugin" 3 | import react from '@vitejs/plugin-react' 4 | import eslintPlugin from '@nabla/vite-plugin-eslint' 5 | import svgr from 'vite-plugin-svgr' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig(({ command, mode }) => { 9 | const env = loadEnv(mode, process.cwd(), '') 10 | const backendUrl = env.BACKEND_API_URL 11 | if (typeof backendUrl !== 'string') { 12 | throw new Error('BACKEND_API_URL environment variable not set during build') 13 | } 14 | return { 15 | build: { 16 | sourcemap: true, 17 | }, 18 | server: { 19 | port: 3000, 20 | proxy: { 21 | '/api': { 22 | target: backendUrl, 23 | changeOrigin: true, 24 | rewrite: (path) => path.replace(/^\/api/, ''), 25 | }, 26 | }, 27 | watch: { 28 | usePolling: true 29 | } 30 | }, 31 | plugins: [ 32 | react(), 33 | eslintPlugin(), 34 | svgr(), 35 | sentryVitePlugin({ 36 | authToken: process.env.SENTRY_AUTH_TOKEN, 37 | org: "planner-ing-uc", 38 | project: "planner-frontend", 39 | }) 40 | ], 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # ========= BUILD CADDY ========= 2 | FROM caddy:2-builder-alpine AS caddy-builder 3 | RUN xcaddy build \ 4 | --with github.com/greenpau/caddy-security \ 5 | --with github.com/greenpau/caddy-trace \ 6 | --with github.com/abiosoft/caddy-exec 7 | 8 | # ========= BUILD APP ========= 9 | FROM node:16-alpine as app-builder 10 | 11 | # Set the working directory 12 | WORKDIR /tmp 13 | 14 | # TODO: copy only the necessary files 15 | # Copy the application code, including config and env files 16 | COPY . . 17 | 18 | # Install dependencies 19 | RUN npm ci 20 | 21 | # Build the application 22 | RUN npm run build 23 | 24 | # ========= RUN ========= 25 | FROM caddy:2-alpine 26 | 27 | # Copy Caddy binary from its builder stage 28 | COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy 29 | 30 | # Install curl for healthcheck 31 | RUN apk --no-cache add curl 32 | 33 | # Copy the build folder from the app builder stage to the Caddy directory 34 | COPY --from=app-builder /tmp/dist /srv 35 | 36 | # Copy Caddyfile for configuration 37 | COPY conf/Caddyfile /etc/caddy/Caddyfile 38 | 39 | # Healthcheck 40 | HEALTHCHECK CMD curl --fail http://localhost:80/ || exit 1 41 | 42 | # Run caddy using config file 43 | CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] 44 | 45 | -------------------------------------------------------------------------------- /backend/tests/test_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from app.sync.siding.client import AcademicPeriod 3 | from pydantic import BaseModel, ValidationError 4 | 5 | 6 | class AcademicPeriodTest(BaseModel): 7 | academic_period: AcademicPeriod 8 | 9 | 10 | def test_academic_period(): 11 | AcademicPeriodTest(academic_period=AcademicPeriod("2021-1")) 12 | AcademicPeriodTest(academic_period=AcademicPeriod("2023-2")) 13 | AcademicPeriodTest(academic_period=AcademicPeriod("2025-3")) 14 | 15 | with pytest.raises(ValidationError): 16 | AcademicPeriodTest(academic_period=AcademicPeriod("2021-0")) 17 | 18 | with pytest.raises(ValidationError): 19 | AcademicPeriodTest(academic_period=AcademicPeriod("2021-4")) 20 | 21 | with pytest.raises(ValidationError): 22 | AcademicPeriodTest(academic_period=AcademicPeriod("2021-5")) 23 | 24 | with pytest.raises(ValidationError): 25 | AcademicPeriodTest(academic_period=AcademicPeriod("KDSFDF")) 26 | 27 | with pytest.raises(ValidationError): 28 | AcademicPeriodTest(academic_period=AcademicPeriod(None)) 29 | 30 | # Test deserialization from a dict with str 31 | d = { 32 | "academic_period": "2021-1", 33 | } 34 | apt = AcademicPeriodTest.parse_obj(d) 35 | assert str(apt.academic_period) == "2021-1" 36 | -------------------------------------------------------------------------------- /backend/typings/cas/prisma/engine/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import sys 6 | import decimal 7 | import datetime 8 | import logging 9 | from builtins import bool as _bool, float as _float, int as _int, str as _str 10 | from typing import Any, Callable, Dict, Generic, Iterable, Iterator, List, Mapping, Optional, Set, TYPE_CHECKING, Tuple, Type, Union, cast, overload 11 | from typing_extensions import Literal, LiteralString, TypedDict 12 | from . import errors, utils 13 | from .abstract import AbstractEngine 14 | from ..http import HTTP 15 | from .._types import Method 16 | 17 | __all__ = ('HTTPEngine', ) 18 | log: logging.Logger = ... 19 | class HTTPEngine(AbstractEngine): 20 | """Engine wrapper that communicates to the underlying engine over HTTP""" 21 | url: Optional[str] 22 | session: HTTP 23 | headers: Dict[str, str] 24 | def __init__(self, url: Optional[str], headers: Optional[Dict[str, str]] = ..., **kwargs: Any) -> None: 25 | ... 26 | 27 | def __del__(self) -> None: 28 | ... 29 | 30 | def close(self) -> None: 31 | ... 32 | 33 | async def aclose(self) -> None: 34 | ... 35 | 36 | async def request(self, method: Method, path: str, *, content: Any = ...) -> Any: 37 | ... 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /backend/typings/cas/tomlkit/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import string 6 | 7 | class TOMLChar(str): 8 | def __init__(self, c) -> None: 9 | ... 10 | 11 | BARE = ... 12 | KV = ... 13 | NUMBER = ... 14 | SPACES = ... 15 | NL = ... 16 | WS = ... 17 | def is_bare_key_char(self) -> bool: 18 | """ 19 | Whether the character is a valid bare key name or not. 20 | """ 21 | ... 22 | 23 | def is_kv_sep(self) -> bool: 24 | """ 25 | Whether the character is a valid key/value separator or not. 26 | """ 27 | ... 28 | 29 | def is_int_float_char(self) -> bool: 30 | """ 31 | Whether the character if a valid integer or float value character or not. 32 | """ 33 | ... 34 | 35 | def is_ws(self) -> bool: 36 | """ 37 | Whether the character is a whitespace character or not. 38 | """ 39 | ... 40 | 41 | def is_nl(self) -> bool: 42 | """ 43 | Whether the character is a new line character or not. 44 | """ 45 | ... 46 | 47 | def is_spaces(self) -> bool: 48 | """ 49 | Whether the character is a space or not 50 | """ 51 | ... 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /docs/api-soap-siding.md: -------------------------------------------------------------------------------- 1 | 2 | # Detalles importantes sobre la API SOAP de Siding 3 | 4 | ## Overview 5 | 6 | La API de Siding es un webservice SOAP. 7 | Esto significa que la interfaz es definida por un archivo `.wsdl`. 8 | El URL de este archivo se entrega a Planner a través de variables de entorno en producción, y los desarrolladores con acceso al webservice pueden ingresarlo también en el ambiente de desarrollo. 9 | 10 | ## Observaciones de la API 11 | 12 | - Cuando el webservice entrega una lista vacía, muchas veces entrega un valor nulo en lugar de la lista vacía. 13 | - Los planes de estudio están bastante incompletos, hay muchas reglas no representables en el formato utilizado. 14 | 15 | Estas observaciones posiblemente están desactualizadas: 16 | 17 | - El endpoint `getConcentracionCursos` lanza un XML invalido para combinaciones invalidas de major-minor-titulo (pero arroja status 200 OK). Tambien lo lanza para algunas combinaciones validas (eg. `C2020-M073-N206-40006`). 18 | - Faltan muchos datos. 19 | Casi todas las combinaciones excepto algunas como `C2020-M170-N776-40082` entregan una lista vacia en `getMallaSugerida`. 20 | - Hay majors sin minors asociados, como `M186 - Major en Ingenieria Civil - Track en Diseno y Construccion de Obras`. 21 | Esto es correcto segun nuestra reunion con Daniela: algunos majors ya tienen los minors "incluidos". 22 | -------------------------------------------------------------------------------- /backend/typings/cas/dotenv/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import re 6 | from abc import ABCMeta 7 | from typing import Iterator, Mapping, Optional, Pattern 8 | 9 | _posix_variable: Pattern[str] = ... 10 | class Atom: 11 | __metaclass__ = ABCMeta 12 | def __ne__(self, other: object) -> bool: 13 | ... 14 | 15 | def resolve(self, env: Mapping[str, Optional[str]]) -> str: 16 | ... 17 | 18 | 19 | 20 | class Literal(Atom): 21 | def __init__(self, value: str) -> None: 22 | ... 23 | 24 | def __repr__(self) -> str: 25 | ... 26 | 27 | def __eq__(self, other: object) -> bool: 28 | ... 29 | 30 | def __hash__(self) -> int: 31 | ... 32 | 33 | def resolve(self, env: Mapping[str, Optional[str]]) -> str: 34 | ... 35 | 36 | 37 | 38 | class Variable(Atom): 39 | def __init__(self, name: str, default: Optional[str]) -> None: 40 | ... 41 | 42 | def __repr__(self) -> str: 43 | ... 44 | 45 | def __eq__(self, other: object) -> bool: 46 | ... 47 | 48 | def __hash__(self) -> int: 49 | ... 50 | 51 | def resolve(self, env: Mapping[str, Optional[str]]) -> str: 52 | ... 53 | 54 | 55 | 56 | def parse_variables(value: str) -> Iterator[Atom]: 57 | ... 58 | 59 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | De partida, muchas gracias por contribuir al proyecto 🚀 4 | 5 | ## Workflow (Flujo de trabajo) 6 | 7 | 1. (Recomendado) Discutir tu propuesta previamente creando una issue. 8 | 2. [Crea y clona un _fork_ del repositorio](https://docs.github.com/es/get-started/quickstart/fork-a-repo) para hacer tus cambios, o si tienes los permisos, crear una nueva branch bajo el mismo repositorio. Si ya tienes fork, [sincroniza el repositorio con upstream](https://docs.github.com/es/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork). 9 | 3. Crear una branch a partir de la branch principal con un nombre significativo. 10 | 4. Añade commits con tus cambios. Usa el estilo de [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) para mantener un historial de cambios ordenado y legible. 11 | 5. [Crea un Pull Request](https://docs.github.com/es/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). Entrega una descripción de los cambios, notas importantes, y añade que issues se están resolviendo. 12 | 6. Asegurate que los _checks_ de GitHub pasen. Si no pasan, revisa los errores y corrige los problemas. Además, si es que se te solicitan cambios, corrígelos. 13 | 7. Si es que la PR es aprobada, un mantenedor del proyecto hará merge y será parte del proyecto 🚀 14 | -------------------------------------------------------------------------------- /frontend/src/client/models/ValidationResult.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { AmbiguousCourseWarn } from './AmbiguousCourseWarn'; 6 | import type { CourseRequirementErr } from './CourseRequirementErr'; 7 | import type { CurriculumErr } from './CurriculumErr'; 8 | import type { MismatchedCurriculumSelectionWarn } from './MismatchedCurriculumSelectionWarn'; 9 | import type { MismatchedCyearErr } from './MismatchedCyearErr'; 10 | import type { NoMajorMinorWarn } from './NoMajorMinorWarn'; 11 | import type { OutdatedPlanErr } from './OutdatedPlanErr'; 12 | import type { RecolorWarn } from './RecolorWarn'; 13 | import type { SemesterCreditsDiag } from './SemesterCreditsDiag'; 14 | import type { SemestralityWarn } from './SemestralityWarn'; 15 | import type { UnassignedWarn } from './UnassignedWarn'; 16 | import type { UnavailableCourseWarn } from './UnavailableCourseWarn'; 17 | import type { UnknownCourseErr } from './UnknownCourseErr'; 18 | import type { UnknownSpecErr } from './UnknownSpecErr'; 19 | 20 | export type ValidationResult = { 21 | diagnostics: Array<(CourseRequirementErr | UnknownCourseErr | MismatchedCyearErr | MismatchedCurriculumSelectionWarn | OutdatedPlanErr | SemestralityWarn | UnavailableCourseWarn | AmbiguousCourseWarn | SemesterCreditsDiag | RecolorWarn | CurriculumErr | UnassignedWarn | NoMajorMinorWarn | UnknownSpecErr)>; 22 | course_superblocks: Record<string, Array<string>>; 23 | }; 24 | 25 | -------------------------------------------------------------------------------- /frontend/.env.production: -------------------------------------------------------------------------------- 1 | ################################################################################################ 2 | # No es necesario modificar este archivo durante el proceso de deploy. # 3 | # # 4 | # Nota para los desarrolladores: # 5 | # Aquí van las variables de entorno para producción que requieren ser definidas antes de # 6 | # levantar el contenedor. Si una variable se utiliza en el código, entonces debe ser definida # 7 | # con un valor predeterminado directamente en el código en vez de aquí. # 8 | # # 9 | # Notar que este archivo cumple un propósito similar a .env.default del backend, pero nunca # 10 | # existirán secretos en el frontend, por lo que no es necesario tener un .env oculto. # 11 | ################################################################################################ 12 | 13 | # Variable requerida para configurar Caddy. 14 | PLANNER_URL=https://mallas.ing.uc.cl 15 | 16 | # Variables requeridas para configurar Vite. 17 | BACKEND_API_URL=http://api:8000 18 | VITE_CAS_SERVER_URL=https://sso.uc.cl/cas/ 19 | VITE_BASE_API_URL=/api 20 | VITE_HIDE_LOGIN=false 21 | 22 | # Variable requerida para definir el ambiente de ejecución. 23 | NODE_ENV=production 24 | -------------------------------------------------------------------------------- /backend/typings/cas/starlette/middleware/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import functools 6 | import re 7 | import typing 8 | from starlette.datastructures import Headers, MutableHeaders 9 | from starlette.responses import PlainTextResponse, Response 10 | from starlette.types import ASGIApp, Message, Receive, Scope, Send 11 | 12 | ALL_METHODS = ... 13 | SAFELISTED_HEADERS = ... 14 | class CORSMiddleware: 15 | def __init__(self, app: ASGIApp, allow_origins: typing.Sequence[str] = ..., allow_methods: typing.Sequence[str] = ..., allow_headers: typing.Sequence[str] = ..., allow_credentials: bool = ..., allow_origin_regex: typing.Optional[str] = ..., expose_headers: typing.Sequence[str] = ..., max_age: int = ...) -> None: 16 | ... 17 | 18 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 19 | ... 20 | 21 | def is_allowed_origin(self, origin: str) -> bool: 22 | ... 23 | 24 | def preflight_response(self, request_headers: Headers) -> Response: 25 | ... 26 | 27 | async def simple_response(self, scope: Scope, receive: Receive, send: Send, request_headers: Headers) -> None: 28 | ... 29 | 30 | async def send(self, message: Message, send: Send, request_headers: Headers) -> None: 31 | ... 32 | 33 | @staticmethod 34 | def allow_explicit_origin(headers: MutableHeaders, origin: str) -> None: 35 | ... 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /cas-mock/data/cas-mock-users.json.example: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "testuser", 4 | "attributes": { 5 | "mail": "testuser@email.com", 6 | "carlicense": "012345678-K" 7 | } 8 | }, 9 | { 10 | "name": "user1", 11 | "attributes": { 12 | "mail": "user1@email.com", 13 | "carlicense": "011111111-1" 14 | } 15 | }, 16 | { 17 | "name": "user2", 18 | "attributes": { 19 | "mail": "user2@email.com", 20 | "carlicense": "022222222-2" 21 | } 22 | }, 23 | { 24 | "name": "user3", 25 | "attributes": { 26 | "mail": "user3@email.com", 27 | "carlicense": "033333333-3" 28 | } 29 | }, 30 | { 31 | "name": "user4", 32 | "attributes": { 33 | "mail": "user4@email.com", 34 | "carlicense": "044444444-4" 35 | } 36 | }, 37 | { 38 | "name": "empty2019", 39 | "attributes": { 40 | "mail": "empty2019@email.com", 41 | "carlicense": "2019-9" 42 | } 43 | }, 44 | { 45 | "name": "empty2021", 46 | "attributes": { 47 | "mail": "empty2021@email.com", 48 | "carlicense": "2021-1" 49 | } 50 | }, 51 | { 52 | "name": "empty2022", 53 | "attributes": { 54 | "mail": "empty2022@email.com", 55 | "carlicense": "2022-2" 56 | } 57 | } 58 | ] 59 | -------------------------------------------------------------------------------- /frontend/src/pages/user/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth } from '../../contexts/auth.context' 2 | 3 | const UserInfo = (): JSX.Element => { 4 | const authState = useAuth() 5 | 6 | return ( 7 | <> 8 | <div className="flex flex-col mb-4 h-full sentry-mask"> 9 | <div className="h-1/2 m-3"> 10 | <h3 className="text-4xl font-normal leading-normal mt-0 mb-2 text-gray-800">Información Personal</h3> 11 | <ul className="m-3 space-y-2"> 12 | <li>Nombre: {authState?.student?.full_name}</li> 13 | {/* TODO: Fix this when we have time */} 14 | {/* <li>Ingreso: {String(authState?.student?.admission.slice(0, -1)) + '-' + String(authState?.student?.admission.slice(-1))}</li> */} 15 | <li>Rut: 20426136-2 </li> 16 | </ul> 17 | </div> 18 | <hr /> 19 | <div className="h-1/2 m-3"> 20 | <h3 className="text-4xl font-normal leading-normal mt-0 mb-2 text-gray-800">Información Académica (Según SIDING)</h3> 21 | <ul className="m-3 space-y-2"> 22 | <li>Título: {authState?.student?.reported_title ?? 'No Inscrito'} </li> 23 | <li>Major: {authState?.student?.reported_major ?? 'No declarado'} </li> 24 | <li>Minor: {authState?.student?.reported_minor ?? 'No declarado'} </li> 25 | </ul> 26 | </div> 27 | </div> 28 | </> 29 | 30 | ) 31 | } 32 | 33 | export default UserInfo 34 | -------------------------------------------------------------------------------- /frontend/src/components/DebugGraph.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { DefaultService, type ValidatablePlan } from '../client' 3 | import { useEffect } from 'react' 4 | 5 | const DebugGraph = ({ validatablePlan }: { validatablePlan: ValidatablePlan | null }): JSX.Element => { 6 | // https://stackoverflow.com/questions/61740073/how-to-detect-keydown-anywhere-on-page-in-a-react-app 7 | useEffect(() => { 8 | const showGraph = async (mode: string): Promise<void> => { 9 | if (validatablePlan === null) return 10 | const g = await DefaultService.getCurriculumValidationGraph(mode, validatablePlan) 11 | window.open(`https://dreampuf.github.io/GraphvizOnline/#${encodeURI(g)}`) 12 | } 13 | 14 | const handleWrapper = (e: KeyboardEvent): void => { 15 | let mode = null 16 | switch (e.key) { 17 | case 'F1': mode = 'pretty'; break 18 | case 'F2': mode = 'debug'; break 19 | case 'F3': mode = 'raw'; break 20 | } 21 | if (mode != null) { 22 | e.preventDefault() 23 | showGraph(mode).then(() => { 24 | console.log('curriculum graph opened in pop-up window') 25 | }).catch(e => { console.error(e) }) 26 | } 27 | } 28 | 29 | document.addEventListener('keydown', handleWrapper) 30 | 31 | // Don't forget to clean up 32 | return () => { 33 | document.removeEventListener('keydown', handleWrapper) 34 | } 35 | }, [validatablePlan]) 36 | 37 | return ( 38 | <></> 39 | ) 40 | } 41 | 42 | export default DebugGraph 43 | -------------------------------------------------------------------------------- /frontend/src/pages/mod/SearchPlanByRutDialog.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState } from 'react' 2 | import TextInputModal from '../../components/TextInputModal' 3 | 4 | const SearchPlanByRutModal = ({ isOpen, status, error, studentInitialSearch, onClose, searchUser }: { isOpen: boolean, status: 'error' | 'success' | 'loading', error: unknown, studentInitialSearch: string, onClose: Function, searchUser: Function }): JSX.Element => { 5 | const [studentRut, setStudentRut] = useState<string>(studentInitialSearch) 6 | const isSaveButtonDisabled: boolean = studentRut.length < 2 7 | 8 | const handleInputChange: React.EventHandler<React.ChangeEvent<HTMLInputElement>> = e => { 9 | const input = e.target.value 10 | let cleanedInput = input.replace(/[^0-9kK]/g, '') 11 | if (cleanedInput.length > 7) cleanedInput = cleanedInput.slice(0, -1) + '-' + cleanedInput.slice(-1) 12 | if (cleanedInput.length < 11 && (e.target.value === '' || cleanedInput !== '')) setStudentRut(cleanedInput) 13 | } 14 | 15 | return ( 16 | <TextInputModal 17 | title="Rut del estudiante" 18 | isOpen={isOpen} 19 | handleAccept={searchUser} 20 | handleInputChange={handleInputChange} 21 | onClose={onClose} 22 | acceptMessage="Buscar" 23 | error={status === 'error'} 24 | errorMsg="Estudiante no encontrado." 25 | isLoading={status === 'loading'} 26 | inputValue={studentRut} 27 | isAcceptButtonDisabled={isSaveButtonDisabled} 28 | /> 29 | ) 30 | } 31 | 32 | export default memo(SearchPlanByRutModal) 33 | -------------------------------------------------------------------------------- /frontend/src/pages/planner/utils/ReceivePaste.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useEffect } from 'react' 3 | import { type ConcreteId, type ValidatablePlan } from '../../../client' 4 | 5 | const ReceivePaste = ({ validatablePlan, getDefaultPlan }: { validatablePlan: ValidatablePlan | null, getDefaultPlan: Function }): JSX.Element => { 6 | useEffect(() => { 7 | const handlePaste = async (e: ClipboardEvent): Promise<void> => { 8 | const text = e.clipboardData?.getData('text/plain') 9 | if (text == null) return 10 | const courseRegex = /[A-Z]{3}\d{3}[A-Z\d]?/g 11 | const periodRegex = /\d-\d{4}/g 12 | const semesters = text.split(periodRegex).slice(1).map(period => [...period.matchAll(courseRegex)].map(match => match[0])) 13 | if (semesters.length === 0 || semesters.some(sem => sem.length === 0)) return 14 | console.log('pasting', semesters) 15 | const newClasses = semesters.map(sem => sem.map((courseCode): ConcreteId => ({ is_concrete: true, code: courseCode }))) 16 | const basePlan = { ...validatablePlan, classes: newClasses } 17 | await getDefaultPlan(basePlan, newClasses.length) 18 | } 19 | 20 | const wrapper = (e: ClipboardEvent): void => { void handlePaste(e) } 21 | 22 | window.addEventListener('paste', wrapper) 23 | 24 | // Don't forget to clean up 25 | return () => { 26 | window.removeEventListener('paste', wrapper) 27 | } 28 | }, [validatablePlan, getDefaultPlan]) 29 | 30 | return ( 31 | <></> 32 | ) 33 | } 34 | 35 | export default ReceivePaste 36 | -------------------------------------------------------------------------------- /backend/typings/cas/httpcore/backends/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import ssl 6 | import typing 7 | import anyio 8 | from .._exceptions import ConnectError, ConnectTimeout, ReadError, ReadTimeout, WriteError, WriteTimeout, map_exceptions 9 | from .._utils import is_socket_readable 10 | from .base import AsyncNetworkBackend, AsyncNetworkStream 11 | 12 | class AsyncIOStream(AsyncNetworkStream): 13 | def __init__(self, stream: anyio.abc.ByteStream) -> None: 14 | ... 15 | 16 | async def read(self, max_bytes: int, timeout: typing.Optional[float] = ...) -> bytes: 17 | ... 18 | 19 | async def write(self, buffer: bytes, timeout: typing.Optional[float] = ...) -> None: 20 | ... 21 | 22 | async def aclose(self) -> None: 23 | ... 24 | 25 | async def start_tls(self, ssl_context: ssl.SSLContext, server_hostname: typing.Optional[str] = ..., timeout: typing.Optional[float] = ...) -> AsyncNetworkStream: 26 | ... 27 | 28 | def get_extra_info(self, info: str) -> typing.Any: 29 | ... 30 | 31 | 32 | 33 | class AsyncIOBackend(AsyncNetworkBackend): 34 | async def connect_tcp(self, host: str, port: int, timeout: typing.Optional[float] = ..., local_address: typing.Optional[str] = ...) -> AsyncNetworkStream: 35 | ... 36 | 37 | async def connect_unix_socket(self, path: str, timeout: typing.Optional[float] = ...) -> AsyncNetworkStream: 38 | ... 39 | 40 | async def sleep(self, seconds: float) -> None: 41 | ... 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /backend/typings/cas/markupsafe/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import typing as t 6 | from . import Markup 7 | 8 | def escape(s: t.Any) -> Markup: 9 | """Replace the characters ``&``, ``<``, ``>``, ``'``, and ``"`` in 10 | the string with HTML-safe sequences. Use this if you need to display 11 | text that might contain such characters in HTML. 12 | 13 | If the object has an ``__html__`` method, it is called and the 14 | return value is assumed to already be safe for HTML. 15 | 16 | :param s: An object to be converted to a string and escaped. 17 | :return: A :class:`Markup` string with the escaped text. 18 | """ 19 | ... 20 | 21 | def escape_silent(s: t.Optional[t.Any]) -> Markup: 22 | """Like :func:`escape` but treats ``None`` as the empty string. 23 | Useful with optional values, as otherwise you get the string 24 | ``'None'`` when the value is ``None``. 25 | 26 | >>> escape(None) 27 | Markup('None') 28 | >>> escape_silent(None) 29 | Markup('') 30 | """ 31 | ... 32 | 33 | def soft_str(s: t.Any) -> str: 34 | """Convert an object to a string if it isn't already. This preserves 35 | a :class:`Markup` string rather than converting it back to a basic 36 | string, so it will still be marked as safe and won't be escaped 37 | again. 38 | 39 | >>> value = escape("<User 1>") 40 | >>> value 41 | Markup('<User 1>') 42 | >>> escape(str(value)) 43 | Markup('&lt;User 1&gt;') 44 | >>> escape(soft_str(value)) 45 | Markup('<User 1>') 46 | """ 47 | ... 48 | 49 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | 9 | volumes: 10 | - ../..:/workspaces:cached 11 | 12 | # Overrides default command so things don't shut down after the process ends. 13 | command: sleep infinity 14 | 15 | # Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 16 | # user: root 17 | 18 | # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. 19 | # (Adding the "ports" property to this file will not forward from a Codespace.) 20 | 21 | db: 22 | image: postgres:latest 23 | restart: unless-stopped 24 | volumes: 25 | - postgres-data:/var/lib/postgresql/data 26 | environment: 27 | POSTGRES_USER: postgres 28 | POSTGRES_DB: postgres 29 | POSTGRES_PASSWORD: postgres 30 | 31 | # Runs app on the same network as the app container, allows "forwardPorts" in devcontainer.json function. 32 | network_mode: service:app 33 | 34 | redis: 35 | image: redis:7 36 | restart: unless-stopped 37 | command: redis-server --save 60 1 38 | volumes: 39 | - redis-data:/data 40 | 41 | # Runs app on the same network as the app container, allows "forwardPorts" in devcontainer.json function. 42 | network_mode: service:app 43 | 44 | # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. 45 | # (Adding the "ports" property to this file will not forward from a Codespace.) 46 | 47 | volumes: 48 | postgres-data: 49 | redis-data: 50 | -------------------------------------------------------------------------------- /backend/scripts/startup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.database import prisma 4 | from app.settings import settings 5 | from app.sync.database import ( 6 | COURSEDATA_PACK_ID, 7 | CURRICULUMS_PACK_ID, 8 | NoPackedDataError, 9 | load_packed, 10 | sync_from_external_sources, 11 | ) 12 | from app.sync.siding.client import client 13 | 14 | 15 | # Run upstream sync on startup 16 | async def sync_and_cache_curricular_data(): 17 | logging.basicConfig() 18 | async with prisma: 19 | client.on_startup() 20 | try: 21 | # Determine if coursedata is empty 22 | coursedata_empty = False 23 | try: 24 | await load_packed(COURSEDATA_PACK_ID) 25 | except NoPackedDataError: 26 | coursedata_empty = True 27 | 28 | # Determine if curriculum data is empty 29 | curriculums_empty = False 30 | try: 31 | await load_packed(CURRICULUMS_PACK_ID) 32 | except NoPackedDataError: 33 | curriculums_empty = True 34 | 35 | # Autosync courses if enabled 36 | await sync_from_external_sources( 37 | sync_coursedata=settings.autosync_courses or coursedata_empty, 38 | sync_curriculum=settings.autosync_curriculums or curriculums_empty, 39 | ) 40 | finally: 41 | if client.soap_client: 42 | client.on_shutdown() 43 | 44 | 45 | if __name__ == "__main__": 46 | import asyncio 47 | 48 | print("Running startup script...") 49 | asyncio.run(sync_and_cache_curricular_data()) 50 | -------------------------------------------------------------------------------- /backend/.env.staging.template: -------------------------------------------------------------------------------- 1 | # URL que apunta al planner. 2 | PLANNER_URL="https://mallastest.ing.uc.cl" 3 | 4 | # URL que apunta al servidor de autenticacion CAS. 5 | CAS_SERVER_URL="http://cas_mock_server:3004/" 6 | # Si es que la URL a donde se redirige el usuario es diferente a la URL usada para 7 | # verificar los tokens, entonces `CAS_LOGIN_REDIRECTION_URL` se debe definir para 8 | # sobreescribir a `CAS_SERVER_URL` en donde corresponda. Sino, dejarla en blanco. 9 | CAS_LOGIN_REDIRECTION_URL="http://localhost:3004/" 10 | 11 | # RUT del administrador. 12 | # Es único y tiene el poder de añadir y remover moderadores. 13 | # Si se deja vacío no existirá administrador. 14 | ADMIN_RUT="012345678-K" 15 | 16 | # Secreto para generar y verificar tokens JWT. 17 | # ADVERTENCIA: Si este secreto se filtra cualquier persona podría forjar tokens de autorización 18 | # para cualquier usuario! 19 | JWT_SECRET="placeholder secret, definitely replace this" 20 | 21 | # Credenciales para utilizar el webservice de Siding. 22 | # Dejar ambas variables vacías para utilizar el mock de siding. 23 | SIDING_USERNAME="" 24 | SIDING_PASSWORD="" 25 | 26 | # No debería ser necesario modificar esta variable, a menos que se modifiquen las credenciales 27 | # de la base de datos. En tal caso considerar la siguiente estructura: 28 | # "postgresql://USER:PASSWORD@HOST:PORT/DATABASE" 29 | DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres?schema=public" 30 | 31 | # No debería ser necesario modificar esta variable. 32 | REDIS_URI="redis://redis:6379/0" 33 | 34 | # No debería ser necesario modificar esta variable. 35 | # SIDING_MOCK_PATH="/siding-mock-data/data.json" 36 | 37 | # No modificar 38 | PYTHON_ENV="staging" -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # First, build the dependencies 2 | FROM python:3.11 as requirements-stage 3 | 4 | # Set working directory 5 | WORKDIR /tmp 6 | 7 | # Set environment variables 8 | ENV PYTHONDONTWRITEBYTECODE 1 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | # Install Poetry 12 | RUN pip install --no-cache-dir --upgrade poetry 13 | 14 | # Copy necessary files for installing dependencies 15 | COPY ./pyproject.toml ./poetry.lock* /tmp/ 16 | 17 | # Install dependencies 18 | RUN poetry export -f requirements.txt --without-hashes --output requirements.txt 19 | 20 | # Second, build the final image 21 | FROM tiangolo/uvicorn-gunicorn:python3.11 22 | 23 | # Install system packages 24 | # We use curl for the healthcheck 25 | RUN apt-get update 26 | RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 27 | build-essential curl 28 | 29 | # Install Node 18 (for Prisma) 30 | RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ 31 | DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs \ 32 | build-essential && \ 33 | node --version && \ 34 | npm --version 35 | 36 | # Set environment variables 37 | ENV PYTHONDONTWRITEBYTECODE 1 38 | ENV PYTHONUNBUFFERED 1 39 | 40 | COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt 41 | 42 | # Install deps from the requirements-stage 43 | RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt 44 | 45 | # TODO: copy only the necessary files 46 | # Copy the application code, including config and env files 47 | COPY ./ /app 48 | WORKDIR /app 49 | 50 | # Generate the Prisma client 51 | RUN prisma generate 52 | 53 | ENV PRE_START_PATH /app/scripts/prestart.sh 54 | 55 | # Monitor the app 56 | HEALTHCHECK --start-period=5m CMD curl -f http://localhost:80/health || exit 1 57 | -------------------------------------------------------------------------------- /backend/app/user/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Definitions of basic student information. 3 | """ 4 | 5 | from pydantic import BaseModel 6 | 7 | from app.plan.course import PseudoCourse 8 | from app.plan.validation.curriculum.tree import MajorCode, MinorCode, TitleCode 9 | 10 | 11 | class StudentInfo(BaseModel): 12 | # Full name, all uppercase, with Unicode accents. 13 | full_name: str 14 | 15 | # Curriculum version that applies to this user. 16 | # Note that this is represented as a `str` rather than a `Cyear`. 17 | # This means that the user's curriculum may potentially not be supported! 18 | cyear: str 19 | # Whether the curriculum version has a matching supported version or not. 20 | is_cyear_supported: bool 21 | 22 | # The self-reported major code. 23 | reported_major: MajorCode | None 24 | # The self-reported minor code. 25 | reported_minor: MinorCode | None 26 | # The self-reported title code. 27 | reported_title: TitleCode | None 28 | 29 | # The student's taken courses up to now. 30 | passed_courses: list[list[PseudoCourse]] 31 | # The index of the current semester (or the next semester if currently in between 32 | # semesters). 33 | current_semester: int 34 | # The index of the next semester (ie. if the student is currently coursing a 35 | # semester, it points to the semester after this one). 36 | # This is the index of the first semester where courses have not yet been taken. 37 | # (This property is useful because we do not want to generate errors for semesters 38 | # where their courses have already been taken) 39 | next_semester: int 40 | 41 | # The period that corresponds to the first semester of the student. 42 | # A (year, semester) tuple, or `None` if there is no information. 43 | admission: tuple[int, int] | None 44 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/postgres 3 | { 4 | "name": "Python 3 & PostgreSQL", 5 | "hostRequirements": { 6 | "cpus": 4, 7 | "memory": "8gb" 8 | }, 9 | "dockerComposeFile": "docker-compose.dev.yml", 10 | "service": "app", 11 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 12 | "features": { 13 | "ghcr.io/devcontainers/features/github-cli:1": {}, 14 | "ghcr.io/devcontainers-contrib/features/poetry:1": {} 15 | }, 16 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 17 | // This can be used to network with other containers or the host. 18 | "forwardPorts": [ 19 | 3000, 20 | 8000, 21 | 5432, 22 | 6379 23 | ], 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | "postCreateCommand": "just init", 26 | // Configure tool-specific properties. 27 | "customizations": { 28 | // Configure properties specific to VS Code. 29 | "vscode": { 30 | // Add the IDs of extensions you want installed when the container is created. 31 | "extensions": [ 32 | "GitHub.copilot", 33 | "GitHub.vscode-pull-request-github", 34 | "eamodio.gitlens", 35 | "firefox-devtools.vscode-firefox-debug", 36 | "Prisma.prisma", 37 | "bradlc.vscode-tailwindcss", 38 | "austenc.tailwind-docs", 39 | "dbaeumer.vscode-eslint", 40 | "ms-python.python", 41 | "charliermarsh.ruff", 42 | "kokakiwi.vscode-just", 43 | "redhat.ansible", 44 | "github.vscode-github-actions" 45 | ], 46 | "settings": { 47 | "terminal.integrated.env.linux": { 48 | "NODE_ENV": "development", 49 | "PYTHON_ENV": "development" 50 | } 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /backend/prisma/migrations/20230517164749_relationalize_equivcourses/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `courses` on the `Equivalence` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- DropIndex 8 | DROP INDEX "Curriculum_cyear_major_minor_title_key"; 9 | 10 | -- DropIndex 11 | DROP INDEX "Major_cyear_code_key"; 12 | 13 | -- DropIndex 14 | DROP INDEX "MajorMinor_cyear_major_minor_key"; 15 | 16 | -- DropIndex 17 | DROP INDEX "Minor_cyear_code_key"; 18 | 19 | -- DropIndex 20 | DROP INDEX "Title_cyear_code_key"; 21 | 22 | -- AlterTable 23 | ALTER TABLE "Curriculum" ADD CONSTRAINT "Curriculum_pkey" PRIMARY KEY ("cyear", "major", "minor", "title"); 24 | 25 | -- AlterTable 26 | ALTER TABLE "Equivalence" DROP COLUMN "courses"; 27 | 28 | -- AlterTable 29 | ALTER TABLE "Major" ADD CONSTRAINT "Major_pkey" PRIMARY KEY ("cyear", "code"); 30 | 31 | -- AlterTable 32 | ALTER TABLE "MajorMinor" ADD CONSTRAINT "MajorMinor_pkey" PRIMARY KEY ("cyear", "major", "minor"); 33 | 34 | -- AlterTable 35 | ALTER TABLE "Minor" ADD CONSTRAINT "Minor_pkey" PRIMARY KEY ("cyear", "code"); 36 | 37 | -- AlterTable 38 | ALTER TABLE "Title" ADD CONSTRAINT "Title_pkey" PRIMARY KEY ("cyear", "code"); 39 | 40 | -- CreateTable 41 | CREATE TABLE "EquivalenceCourse" ( 42 | "equiv_code" TEXT NOT NULL, 43 | "course_code" TEXT NOT NULL, 44 | 45 | CONSTRAINT "EquivalenceCourse_pkey" PRIMARY KEY ("equiv_code","course_code") 46 | ); 47 | 48 | -- AddForeignKey 49 | ALTER TABLE "EquivalenceCourse" ADD CONSTRAINT "EquivalenceCourse_course_code_fkey" FOREIGN KEY ("course_code") REFERENCES "Course"("code") ON DELETE RESTRICT ON UPDATE CASCADE; 50 | 51 | -- AddForeignKey 52 | ALTER TABLE "EquivalenceCourse" ADD CONSTRAINT "EquivalenceCourse_equiv_code_fkey" FOREIGN KEY ("equiv_code") REFERENCES "Equivalence"("code") ON DELETE RESTRICT ON UPDATE CASCADE; 53 | -------------------------------------------------------------------------------- /frontend/src/components/GeneralModal.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { memo, Fragment, type MutableRefObject, type ReactNode } from 'react' 3 | import { Dialog, Transition } from '@headlessui/react' 4 | 5 | interface GeneralModalProps { 6 | isOpen: boolean 7 | onClose: Function 8 | initialFocus?: MutableRefObject<any> 9 | children?: ReactNode 10 | } 11 | 12 | const GeneralModal: React.FC<GeneralModalProps> = ({ isOpen, onClose, initialFocus, children }) => { 13 | return ( 14 | <Transition.Root show={isOpen} as={Fragment}> 15 | <Dialog as="div" className="modal relative" initialFocus={initialFocus} onClose={() => onClose()}> 16 | <Transition.Child 17 | as={Fragment} 18 | enter="ease-out duration-300" 19 | enterFrom="opacity-0" 20 | enterTo="opacity-100" 21 | leave="ease-in duration-200" 22 | leaveFrom="opacity-100" 23 | leaveTo="opacity-0" 24 | > 25 | <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> 26 | </Transition.Child> 27 | 28 | <div className="fixed inset-0 overflow-y-auto"> 29 | <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> 30 | <Transition.Child 31 | as={Fragment} 32 | enter="ease-out duration-300" 33 | enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 34 | enterTo="opacity-100 translate-y-0 sm:scale-100" 35 | leave="ease-in duration-200" 36 | leaveFrom="opacity-100 translate-y-0 sm:scale-100" 37 | leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 38 | > 39 | {children} 40 | </Transition.Child> 41 | </div> 42 | </div> 43 | </Dialog> 44 | </Transition.Root> 45 | ) 46 | } 47 | export default memo(GeneralModal) 48 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build-frontend: 11 | runs-on: ubuntu-latest 12 | name: Build frontend (for Sentry source maps) 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: '16' 21 | 22 | - name: Install dependencies 23 | run: npm install 24 | working-directory: frontend 25 | 26 | - name: Build frontend 27 | run: npm run build 28 | working-directory: frontend 29 | 30 | deploy-staging: 31 | runs-on: ubuntu-latest 32 | name: Deploy to staging 33 | timeout-minutes: 15 34 | env: 35 | MACHINE: mallastest # This is the staging server 36 | steps: 37 | - name: Setup Tailscale 38 | uses: tailscale/github-action@main 39 | with: 40 | oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} 41 | oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} 42 | version: 1.44.0 43 | tags: tag:planner-ci 44 | 45 | - name: Add SSH key 46 | env: 47 | SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} 48 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 49 | run: | 50 | mkdir -p ~/.ssh 51 | MACHINE_IP="$(tailscale ip -4 $MACHINE)" 52 | ssh-keyscan $MACHINE_IP >> ~/.ssh/known_hosts 53 | echo "$SSH_KEY" | tr -d '\r' > ~/.ssh/key 54 | chmod 600 ~/.ssh/key 55 | ssh-agent -a $SSH_AUTH_SOCK > /dev/null 56 | ssh-add ~/.ssh/key 57 | 58 | - name: Deploy to server 59 | env: 60 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 61 | run: | 62 | MACHINE_IP="$(tailscale ip -4 $MACHINE)" 63 | USERHOST="planner@$MACHINE_IP" 64 | ssh $USERHOST "cd /opt/planner && just deploy staging" -------------------------------------------------------------------------------- /backend/.env.production.template: -------------------------------------------------------------------------------- 1 | ############################################################################################### 2 | # Este archivo está pensado para ser copiado con el nombre `.env` y ser usado en producción. # 3 | # Es necesario entregar los valores reales a cada una de las variables definidas abajo # 4 | # antes de levantar los contenedores de la aplicación para que funcione correctamente. # 5 | # # 6 | # Nota para los desarrolladores: # 7 | # La idea es que este archivo sea completamente estático, ya que será manejado por la # 8 | # universidad directamente en la máquina de producción. Por esta razón, solo debe ser usado # 9 | # para definir secretos. Cualquier otra variable (e.g. URL de la bbdd) se ingresa a nivel de # 10 | # código para poder ser modificada con un commit al repo. # 11 | # En particular, las variables del backend usadas en código se definen como predeterminadas # 12 | # en el archivo `settings.py`, mientras que Las variables necesarias para levantar los # 13 | # contenedores van en el archivo `.env.default`. # 14 | ############################################################################################### 15 | 16 | # RUT del administrador. 17 | # Es único y tiene el poder de añadir y remover moderadores. 18 | # Si se deja vacío no existirá administrador. 19 | # --> ejemplo: "12345678-K" 20 | # ADMIN_RUT="" 21 | 22 | # Secreto para generar y verificar tokens JWT. 23 | # ADVERTENCIA: Si este secreto se filtra cualquier persona podría forjar tokens de autorización 24 | # para cualquier usuario! 25 | # JWT_SECRET="mal secreto, REEMPLAZAR ESTO por un buen secreto." 26 | 27 | # Credenciales para utilizar el webservice de Siding. 28 | # SIDING_USERNAME="<usuario de siding>" 29 | # SIDING_PASSWORD="<contrasena de siding>" 30 | -------------------------------------------------------------------------------- /frontend/src/client/models/CourseRequirementErr.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* tslint:disable */ 3 | /* eslint-disable */ 4 | 5 | import type { And } from './And'; 6 | import type { ClassId } from './ClassId'; 7 | import type { Const } from './Const'; 8 | import type { MinCredits } from './MinCredits'; 9 | import type { Or } from './Or'; 10 | import type { ReqCareer } from './ReqCareer'; 11 | import type { ReqCourse } from './ReqCourse'; 12 | import type { ReqLevel } from './ReqLevel'; 13 | import type { ReqProgram } from './ReqProgram'; 14 | import type { ReqSchool } from './ReqSchool'; 15 | 16 | /** 17 | * Indicates that a course (`associated_to`) is missing some requirements (`missing`). 18 | * 19 | * - `missing`: The raw missing requirements, as specified in the course requirements. 20 | * This expression is simplified, and only contains the courses that are actually 21 | * missing. 22 | * - `missing_modernized`: Like `missing`, but course codes are replaced by their 23 | * modernized counterparts. 24 | * - `push_back`: If the `associated_to` course can be moved back some semesters an 25 | * then fulfill the requirements, this property is the index of that semester. 26 | * - `pull_forward`: If some requirements already exist in the plan but they are too 27 | * late to count as requirements for the `associate_to` course, they are listed 28 | * here, along with the semester that they would have to be moved to. 29 | * - `add_absent`: Requirements that are not in the plan and have to be added. 30 | * The modernized code is listed here. 31 | */ 32 | export type CourseRequirementErr = { 33 | is_err?: boolean; 34 | kind?: 'req'; 35 | associated_to: Array<ClassId>; 36 | missing: (And | Or | Const | MinCredits | ReqLevel | ReqSchool | ReqProgram | ReqCareer | ReqCourse); 37 | modernized_missing: (And | Or | Const | MinCredits | ReqLevel | ReqSchool | ReqProgram | ReqCareer | ReqCourse); 38 | push_back?: number; 39 | pull_forward: Record<string, number>; 40 | add_absent: Record<string, number>; 41 | }; 42 | 43 | -------------------------------------------------------------------------------- /backend/app/plan/validation/validate.py: -------------------------------------------------------------------------------- 1 | from app.plan.course import PseudoCourse 2 | from app.plan.plan import ValidatablePlan 3 | from app.plan.validation.courses.validate import ValidationContext 4 | from app.plan.validation.curriculum.diagnose import diagnose_curriculum, find_swapouts 5 | from app.plan.validation.diagnostic import ValidationResult 6 | from app.plan.validation.user import validate_against_owner 7 | from app.sync import get_curriculum 8 | from app.sync.database import course_info, curriculum_storage 9 | from app.user.info import StudentInfo 10 | 11 | 12 | async def diagnose_plan( 13 | plan: ValidatablePlan, 14 | user_ctx: StudentInfo | None, 15 | ) -> ValidationResult: 16 | """ 17 | Validate a career plan, checking that all pending courses can actually be taken 18 | (ie. validate their dependencies), and also check that if the plan is followed the 19 | user will get their set major/minor/title degree. 20 | """ 21 | courseinfo = await course_info() 22 | cstore = await curriculum_storage() 23 | curriculum = await get_curriculum(plan.curriculum) 24 | out = ValidationResult.empty(plan) 25 | 26 | # Validate against user context, if there is any context 27 | if user_ctx is not None: 28 | validate_against_owner(courseinfo, curriculum, plan, user_ctx, out) 29 | 30 | # Ensure course requirements are met 31 | course_ctx = ValidationContext(courseinfo, plan, user_ctx) 32 | course_ctx.validate_all(out) 33 | 34 | # Ensure the given curriculum is fulfilled 35 | diagnose_curriculum( 36 | courseinfo, 37 | cstore, 38 | curriculum, 39 | plan, 40 | user_ctx, 41 | out, 42 | ) 43 | 44 | return out 45 | 46 | 47 | async def list_swapouts( 48 | plan: ValidatablePlan, 49 | sem_idx: int, 50 | class_idx: int, 51 | ) -> list[list[PseudoCourse]]: 52 | courseinfo = await course_info() 53 | curriculum = await get_curriculum(plan.curriculum) 54 | return find_swapouts(courseinfo, curriculum, plan, sem_idx, class_idx) 55 | -------------------------------------------------------------------------------- /backend/typings/cas/sniffio/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import sys 6 | import threading 7 | from contextvars import ContextVar 8 | from typing import Optional 9 | 10 | current_async_library_cvar: ContextVar[Optional[str]] = ... 11 | class _ThreadLocal(threading.local): 12 | name: Optional[str] = ... 13 | 14 | 15 | thread_local = ... 16 | class AsyncLibraryNotFoundError(RuntimeError): 17 | ... 18 | 19 | 20 | def current_async_library() -> str: 21 | """Detect which async library is currently running. 22 | 23 | The following libraries are currently supported: 24 | 25 | ================ =========== ============================ 26 | Library Requires Magic string 27 | ================ =========== ============================ 28 | **Trio** Trio v0.6+ ``"trio"`` 29 | **Curio** - ``"curio"`` 30 | **asyncio** ``"asyncio"`` 31 | **Trio-asyncio** v0.8.2+ ``"trio"`` or ``"asyncio"``, 32 | depending on current mode 33 | ================ =========== ============================ 34 | 35 | Returns: 36 | A string like ``"trio"``. 37 | 38 | Raises: 39 | AsyncLibraryNotFoundError: if called from synchronous context, 40 | or if the current async library was not recognized. 41 | 42 | Examples: 43 | 44 | .. code-block:: python3 45 | 46 | from sniffio import current_async_library 47 | 48 | async def generic_sleep(seconds): 49 | library = current_async_library() 50 | if library == "trio": 51 | import trio 52 | await trio.sleep(seconds) 53 | elif library == "asyncio": 54 | import asyncio 55 | await asyncio.sleep(seconds) 56 | # ... and so on ... 57 | else: 58 | raise RuntimeError(f"Unsupported library {library!r}") 59 | 60 | """ 61 | ... 62 | 63 | -------------------------------------------------------------------------------- /backend/typings/cas/h11/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from typing import Any, Callable, Dict, List, Tuple, Type, Union 6 | from ._events import Data, EndOfMessage, Event, InformationalResponse, Request, Response 7 | from ._headers import Headers 8 | from ._state import CLIENT, IDLE, SEND_BODY, SEND_RESPONSE, SERVER 9 | from ._util import LocalProtocolError, Sentinel 10 | 11 | __all__ = ["WRITERS"] 12 | Writer = Callable[[bytes], Any] 13 | def write_headers(headers: Headers, write: Writer) -> None: 14 | ... 15 | 16 | def write_request(request: Request, write: Writer) -> None: 17 | ... 18 | 19 | def write_any_response(response: Union[InformationalResponse, Response], write: Writer) -> None: 20 | ... 21 | 22 | class BodyWriter: 23 | def __call__(self, event: Event, write: Writer) -> None: 24 | ... 25 | 26 | def send_data(self, data: bytes, write: Writer) -> None: 27 | ... 28 | 29 | def send_eom(self, headers: Headers, write: Writer) -> None: 30 | ... 31 | 32 | 33 | 34 | class ContentLengthWriter(BodyWriter): 35 | def __init__(self, length: int) -> None: 36 | ... 37 | 38 | def send_data(self, data: bytes, write: Writer) -> None: 39 | ... 40 | 41 | def send_eom(self, headers: Headers, write: Writer) -> None: 42 | ... 43 | 44 | 45 | 46 | class ChunkedWriter(BodyWriter): 47 | def send_data(self, data: bytes, write: Writer) -> None: 48 | ... 49 | 50 | def send_eom(self, headers: Headers, write: Writer) -> None: 51 | ... 52 | 53 | 54 | 55 | class Http10Writer(BodyWriter): 56 | def send_data(self, data: bytes, write: Writer) -> None: 57 | ... 58 | 59 | def send_eom(self, headers: Headers, write: Writer) -> None: 60 | ... 61 | 62 | 63 | 64 | WritersType = Dict[Union[Tuple[Type[Sentinel], Type[Sentinel]], Type[Sentinel]], Union[Dict[str, Type[BodyWriter]], Callable[[Union[InformationalResponse, Response], Writer], None], Callable[[Request, Writer], None],],] 65 | WRITERS: WritersType = ... 66 | -------------------------------------------------------------------------------- /backend/app/routes/offer.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from fastapi import APIRouter 4 | from prisma.models import ( 5 | Major as DbMajor, 6 | ) 7 | from prisma.models import ( 8 | Minor as DbMinor, 9 | ) 10 | from prisma.models import ( 11 | Title as DbTitle, 12 | ) 13 | from pydantic import BaseModel 14 | 15 | router = APIRouter(prefix="/offer") 16 | 17 | 18 | @router.get("/major", response_model=list[DbMajor]) 19 | async def get_majors(cyear: str): 20 | """ 21 | Get all the available majors for a given curriculum version (cyear). 22 | """ 23 | return await DbMajor.prisma().find_many( 24 | where={ 25 | "cyear": cyear, 26 | }, 27 | ) 28 | 29 | 30 | @router.get("/minor", response_model=list[DbMinor]) 31 | async def get_minors(cyear: str, major_code: str | None = None): 32 | if major_code is None: 33 | return await DbMinor.prisma().find_many( 34 | where={ 35 | "cyear": cyear, 36 | }, 37 | ) 38 | return await DbMinor.prisma().query_raw( 39 | """ 40 | SELECT * 41 | FROM "Minor", "MajorMinor" 42 | WHERE "MajorMinor".minor = "Minor".code 43 | AND "MajorMinor".major = $2 44 | AND "MajorMinor".cyear = $1 45 | AND "Minor".cyear = $1 46 | """, 47 | cyear, 48 | major_code, 49 | ) 50 | 51 | 52 | @router.get("/title", response_model=list[DbTitle]) 53 | async def get_titles(cyear: str): 54 | return await DbTitle.prisma().find_many( 55 | where={ 56 | "cyear": cyear, 57 | }, 58 | ) 59 | 60 | 61 | class FullOffer(BaseModel): 62 | majors: list[DbMajor] 63 | minors: list[DbMinor] 64 | titles: list[DbTitle] 65 | 66 | 67 | @router.get("/", response_model=FullOffer) 68 | async def get_offer(cyear: str, major_code: str | None = None): 69 | majors, minors, titles = await asyncio.gather( 70 | get_majors(cyear), 71 | get_minors(cyear, major_code), 72 | get_titles(cyear), 73 | ) 74 | return FullOffer(majors=majors, minors=minors, titles=titles) 75 | -------------------------------------------------------------------------------- /backend/typings/cas/httpx/_transports/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import io 6 | import itertools 7 | import sys 8 | import typing 9 | from .._models import Request, Response 10 | from .._types import SyncByteStream 11 | from .base import BaseTransport 12 | 13 | class WSGIByteStream(SyncByteStream): 14 | def __init__(self, result: typing.Iterable[bytes]) -> None: 15 | ... 16 | 17 | def __iter__(self) -> typing.Iterator[bytes]: 18 | ... 19 | 20 | def close(self) -> None: 21 | ... 22 | 23 | 24 | 25 | class WSGITransport(BaseTransport): 26 | """ 27 | A custom transport that handles sending requests directly to an WSGI app. 28 | The simplest way to use this functionality is to use the `app` argument. 29 | 30 | ``` 31 | client = httpx.Client(app=app) 32 | ``` 33 | 34 | Alternatively, you can setup the transport instance explicitly. 35 | This allows you to include any additional configuration arguments specific 36 | to the WSGITransport class: 37 | 38 | ``` 39 | transport = httpx.WSGITransport( 40 | app=app, 41 | script_name="/submount", 42 | remote_addr="1.2.3.4" 43 | ) 44 | client = httpx.Client(transport=transport) 45 | ``` 46 | 47 | Arguments: 48 | 49 | * `app` - The ASGI application. 50 | * `raise_app_exceptions` - Boolean indicating if exceptions in the application 51 | should be raised. Default to `True`. Can be set to `False` for use cases 52 | such as testing the content of a client 500 response. 53 | * `script_name` - The root path on which the WSGI application should be mounted. 54 | * `remote_addr` - A string indicating the client IP of incoming requests. 55 | ``` 56 | """ 57 | def __init__(self, app: typing.Callable, raise_app_exceptions: bool = ..., script_name: str = ..., remote_addr: str = ..., wsgi_errors: typing.Optional[typing.TextIO] = ...) -> None: 58 | ... 59 | 60 | def handle_request(self, request: Request) -> Response: 61 | ... 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en" data-theme="MVP"> 3 | 4 | <head> 5 | <meta charset="UTF-8" /> 6 | <link rel="icon" type="image/svg+xml" href="/logo.png" /> 7 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 | <link rel="preconnect" href="https://fonts.googleapis.com"> 9 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 10 | <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> 11 | 12 | <!-- Primary Meta Tags --> 13 | <title>Mallas ING 14 | 15 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 | 38 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /frontend/src/pages/admin/addMod.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState } from 'react' 2 | import TextInputModal from '../../components/TextInputModal' 3 | import { isApiError } from '../planner/utils/Types' 4 | 5 | const AddModByRutModal = ({ isOpen, onClose, addMod }: { isOpen: boolean, onClose: Function, addMod: Function }): JSX.Element => { 6 | const [isLoading, setIsLoading] = useState(false) 7 | const [isRutInvalid, setIsRutInvalid] = useState(false) 8 | const [modRut, setModRut] = useState('') 9 | 10 | const handleInputChange: React.EventHandler> = e => { 11 | const input = e.target.value 12 | let cleanedInput = input.replace(/[^0-9kK]/g, '') 13 | if (cleanedInput.length > 7) cleanedInput = cleanedInput.slice(0, -1) + '-' + cleanedInput.slice(-1) 14 | if (cleanedInput.length < 11 && (e.target.value === '' || cleanedInput !== '')) setModRut(cleanedInput) 15 | } 16 | 17 | const giveModPermit = async (rut: string): Promise => { 18 | setIsRutInvalid(false) 19 | setIsLoading(true) 20 | let formattedRut = rut 21 | if (formattedRut.charAt(formattedRut.length - 2) !== '-') { 22 | formattedRut = formattedRut.slice(0, -1) + '-' + formattedRut.slice(-1) 23 | } 24 | try { 25 | await addMod(formattedRut) 26 | setIsRutInvalid(false) 27 | onClose() 28 | } catch (err) { 29 | if (isApiError(err)) { 30 | if (err.status === 400 || err.status === 422) { 31 | console.log(err.message) 32 | setIsRutInvalid(true) 33 | } 34 | } 35 | } 36 | setIsLoading(false) 37 | } 38 | 39 | return ( 40 | 53 | ) 54 | } 55 | 56 | export default memo(AddModByRutModal) 57 | -------------------------------------------------------------------------------- /backend/.env.development.template: -------------------------------------------------------------------------------- 1 | 2 | # In development mode, use a local server instead of the production URL. 3 | PLANNER_URL="http://localhost:3000" 4 | 5 | # URL to the CAS server endpoint. 6 | # Used for two purposes: 7 | # - When a user arrives with a CAS token the backend verifies the token directly with 8 | # this server. 9 | # - The user's browser is redirected here when they request to log in. 10 | # If the URL the user is redirected to is different to the URL used to verify the 11 | # tokens, the `CAS_LOGIN_REDIRECTION_URL` variable should be set to override it. 12 | # 13 | # In development, use a local CAS mock server. 14 | CAS_SERVER_URL="http://localhost:3004/" 15 | CAS_LOGIN_REDIRECTION_URL="" 16 | 17 | # Admin RUT. Is always the only admin and has the power of adding/removing mods. 18 | # There will be no admins if this string is left empty. 19 | # 20 | # In development, make an invalid test RUT be the admin. 21 | ADMIN_RUT="012345678-K" 22 | 23 | # JWT secret string. If this secret is leaked, anyone can forge JWT tokens for 24 | # any user. 25 | JWT_SECRET="placeholder secret, definitely replace this" 26 | 27 | # Siding base URL to patch the SOAP WebService definition with. 28 | # If "", the app will not attempt to connect to the SIDING webservice at all, 29 | # relying only on the mock responses. 30 | SIDING_HOST_BASE="" 31 | 32 | # Username to the Siding webservice. 33 | SIDING_USERNAME="" 34 | 35 | # Password to the Siding webservice. 36 | # Only available to the Planner development team. 37 | SIDING_PASSWORD="" 38 | 39 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 40 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 41 | DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" 42 | 43 | # In development, connect with redis through localhost instead of through the docker network. 44 | REDIS_URI="redis://localhost:6379" 45 | 46 | # Avoid updating data as much as possible in development mode. 47 | AUTOSYNC_COURSES="false" 48 | AUTOSYNC_CURRICULUMS="false" 49 | 50 | # Don't remove 51 | PYTHON_ENV="development" -------------------------------------------------------------------------------- /backend/typings/cas/anyio/abc/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import types 6 | from abc import ABCMeta, abstractmethod 7 | from collections.abc import AsyncGenerator, Iterable 8 | from typing import Any, Callable, Coroutine, Dict, Optional, Type, TypeVar 9 | 10 | _T = TypeVar("_T") 11 | class TestRunner(metaclass=ABCMeta): 12 | """ 13 | Encapsulates a running event loop. Every call made through this object will use the same event 14 | loop. 15 | """ 16 | def __enter__(self) -> TestRunner: 17 | ... 18 | 19 | def __exit__(self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[types.TracebackType]) -> Optional[bool]: 20 | ... 21 | 22 | @abstractmethod 23 | def close(self) -> None: 24 | """Close the event loop.""" 25 | ... 26 | 27 | @abstractmethod 28 | def run_asyncgen_fixture(self, fixture_func: Callable[..., AsyncGenerator[_T, Any]], kwargs: Dict[str, Any]) -> Iterable[_T]: 29 | """ 30 | Run an async generator fixture. 31 | 32 | :param fixture_func: the fixture function 33 | :param kwargs: keyword arguments to call the fixture function with 34 | :return: an iterator yielding the value yielded from the async generator 35 | """ 36 | ... 37 | 38 | @abstractmethod 39 | def run_fixture(self, fixture_func: Callable[..., Coroutine[Any, Any, _T]], kwargs: Dict[str, Any]) -> _T: 40 | """ 41 | Run an async fixture. 42 | 43 | :param fixture_func: the fixture function 44 | :param kwargs: keyword arguments to call the fixture function with 45 | :return: the return value of the fixture function 46 | """ 47 | ... 48 | 49 | @abstractmethod 50 | def run_test(self, test_func: Callable[..., Coroutine[Any, Any, Any]], kwargs: Dict[str, Any]) -> None: 51 | """ 52 | Run an async test function. 53 | 54 | :param test_func: the test function 55 | :param kwargs: keyword arguments to call the test function with 56 | """ 57 | ... 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | networks: 4 | caddy: 5 | api: 6 | 7 | 8 | services: 9 | planner: 10 | build: ./frontend 11 | container_name: planner-web 12 | restart: always 13 | ports: 14 | - "80:80" 15 | - "443:443" 16 | networks: 17 | - caddy 18 | depends_on: 19 | - api 20 | env_file: 21 | - ./frontend/.env.production 22 | volumes: 23 | - caddy_data:/data 24 | - caddy_config:/config 25 | api: 26 | build: ./backend 27 | container_name: planner-api 28 | restart: always 29 | ports: 30 | - "8000:80" 31 | networks: 32 | - caddy 33 | - api 34 | depends_on: 35 | db: 36 | condition: service_healthy 37 | redis: 38 | condition: service_healthy 39 | volumes: 40 | - ./siding-mock-data:/siding-mock-data 41 | - ./static-curriculum-data:/static-curriculum-data 42 | env_file: 43 | - ./backend/.env.default 44 | - ./backend/.env 45 | 46 | db: 47 | image: postgres:15 48 | container_name: planner-db 49 | restart: always 50 | networks: 51 | - api 52 | volumes: 53 | - postgres-data:/var/lib/postgresql/data 54 | environment: 55 | POSTGRES_USER: postgres 56 | POSTGRES_DB: postgres 57 | POSTGRES_PASSWORD: postgres 58 | healthcheck: 59 | test: [ "CMD-SHELL", "pg_isready -U postgres" ] 60 | 61 | redis: 62 | image: redis:7 63 | container_name: planner-redis 64 | restart: always 65 | networks: 66 | - api 67 | command: redis-server --save 60 1 68 | volumes: 69 | - redis-data:/data 70 | healthcheck: 71 | test: [ "CMD-SHELL", "redis-cli ping | grep -q PONG" ] 72 | 73 | cas_mock_server: 74 | # Run a local cas mock server container for developing purposes. 75 | # Don't run this service in production. 76 | image: node:16-alpine 77 | restart: always 78 | working_dir: /app 79 | ports: 80 | - "3004:3004" 81 | networks: 82 | - caddy 83 | volumes: 84 | - ./cas-mock/data/cas-mock-users.json:/app/data/cas-mock-users.json 85 | 86 | volumes: 87 | postgres-data: 88 | redis-data: 89 | caddy_config: 90 | caddy_data: 91 | external: true 92 | -------------------------------------------------------------------------------- /frontend/src/utils/useContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, type MouseEventHandler } from 'react' 2 | 3 | interface useContextMenuReturn { 4 | clicked: boolean 5 | points: { x: number, y: number } 6 | handleContextMenu: MouseEventHandler 7 | setClicked: Function 8 | courseInfo: { code: string, instance: number, credits: number, isEquivalence: boolean } 9 | } 10 | 11 | const useContextMenu = (): useContextMenuReturn => { 12 | const [clicked, setClicked] = useState(false) 13 | const [points, setPoints] = useState({ 14 | x: 0, 15 | y: 0 16 | }) 17 | 18 | const [courseInfo, setCourseInfo] = useState({ 19 | code: '', 20 | instance: 0, 21 | isEquivalence: false, 22 | credits: 0 23 | }) 24 | const handleContextMenu = (e: React.MouseEvent): void => { 25 | e.preventDefault() 26 | setClicked(true) 27 | setPoints({ 28 | x: e.pageX, 29 | y: e.pageY 30 | }) 31 | const courseCode = e.currentTarget.getAttribute('data-course-code') 32 | const courseInstance = e.currentTarget.getAttribute('data-course-instance') 33 | const courseIsEquiv = e.currentTarget.getAttribute('data-course-hasequiv') 34 | const courseCredits = e.currentTarget.getAttribute('data-course-credits') 35 | 36 | if (courseCode != null && courseInstance != null && courseIsEquiv != null && courseCredits != null) { 37 | setCourseInfo({ 38 | code: courseCode, 39 | instance: parseInt(courseInstance), 40 | isEquivalence: courseIsEquiv === 'true', 41 | credits: parseInt(courseCredits) 42 | }) 43 | } 44 | // console.log('Right Click', courseCode, courseInstance, e.pageX, e.pageY) 45 | } 46 | useEffect(() => { 47 | const handleClick = (e: MouseEvent): void => { 48 | const target = e.target as HTMLElement 49 | const contextMenu = document.getElementById('context-menu') 50 | if ((contextMenu != null) && !contextMenu.contains(target)) { 51 | setClicked(false) 52 | } 53 | } 54 | document.addEventListener('mousedown', handleClick) 55 | return () => { 56 | document.removeEventListener('mousedown', handleClick) 57 | } 58 | }, []) 59 | return { 60 | clicked, 61 | setClicked, 62 | points, 63 | courseInfo, 64 | handleContextMenu 65 | } 66 | } 67 | export default useContextMenu 68 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "panguipath-app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "generate": "openapi --input http://localhost:8000/openapi.json --output ./src/client --client axios --useUnionTypes", 11 | "type-check": "tsc --noEmit", 12 | "lint": "eslint --ext .js,.jsx,.ts,.tsx src && tsc --noEmit", 13 | "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx src --fix", 14 | "format": "eslint --ext .js,.jsx,.ts,.tsx src --fix --fix-type [layout]" 15 | }, 16 | "dependencies": { 17 | "@headlessui/react": "^1.7.4", 18 | "@headlessui/tailwindcss": "^0.1.1", 19 | "@nabla/vite-plugin-eslint": "^1.4.1", 20 | "@sentry/react": "^7.58.1", 21 | "@sentry/vite-plugin": "^2.4.0", 22 | "@tailwindcss/forms": "^0.5.3", 23 | "@tailwindcss/line-clamp": "^0.4.2", 24 | "@tailwindcss/typography": "^0.5.8", 25 | "@tanstack/react-query": "^4.35.3", 26 | "@tanstack/react-router": "=0.0.1-beta.83", 27 | "fast-deep-equal": "^3.1.3", 28 | "js-confetti": "^0.11.0", 29 | "react": "^18.2.0", 30 | "react-dnd": "^16.0.1", 31 | "react-dnd-html5-backend": "^16.0.1", 32 | "react-dnd-scrolling": "^1.3.3", 33 | "react-dom": "^18.2.0", 34 | "react-toastify": "^9.1.2", 35 | "vite-plugin-svgr": "^3.2.0" 36 | }, 37 | "devDependencies": { 38 | "@types/node": "^20.3.2", 39 | "@types/react": "^18.0.24", 40 | "@types/react-dom": "^18.0.8", 41 | "@typescript-eslint/eslint-plugin": "^5.45.0", 42 | "@typescript-eslint/parser": "^5.45.0", 43 | "@vitejs/plugin-react": "^4.0.1", 44 | "autoprefixer": "^10.4.13", 45 | "axios": "^1.2.1", 46 | "eslint": "^8.28.0", 47 | "eslint-config-standard-with-typescript": "^35.0.0", 48 | "eslint-plugin-import": "^2.26.0", 49 | "eslint-plugin-jsx-a11y": "^6.6.1", 50 | "eslint-plugin-n": "^15.7.0", 51 | "eslint-plugin-promise": "^6.1.1", 52 | "eslint-plugin-react": "^7.31.11", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "eslint-plugin-react-refresh": "^0.4.3", 55 | "form-data": "^4.0.0", 56 | "openapi-typescript-codegen": "^0.24.0", 57 | "postcss": "^8.4.19", 58 | "tailwindcss": "^3.2.4", 59 | "typescript": "^5.1.3", 60 | "vite": "^4.3.9" 61 | } 62 | } -------------------------------------------------------------------------------- /backend/typings/cas/httpx/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import binascii 6 | import io 7 | import os 8 | import typing 9 | from pathlib import Path 10 | from ._types import AsyncByteStream, FileContent, FileTypes, RequestData, RequestFiles, SyncByteStream 11 | from ._utils import format_form_param, guess_content_type, peek_filelike_length, primitive_value_to_str, to_bytes 12 | 13 | def get_multipart_boundary_from_content_type(content_type: typing.Optional[bytes]) -> typing.Optional[bytes]: 14 | ... 15 | 16 | class DataField: 17 | """ 18 | A single form field item, within a multipart form field. 19 | """ 20 | def __init__(self, name: str, value: typing.Union[str, bytes, int, float, None]) -> None: 21 | ... 22 | 23 | def render_headers(self) -> bytes: 24 | ... 25 | 26 | def render_data(self) -> bytes: 27 | ... 28 | 29 | def get_length(self) -> int: 30 | ... 31 | 32 | def render(self) -> typing.Iterator[bytes]: 33 | ... 34 | 35 | 36 | 37 | class FileField: 38 | """ 39 | A single file field item, within a multipart form field. 40 | """ 41 | CHUNK_SIZE = ... 42 | def __init__(self, name: str, value: FileTypes) -> None: 43 | ... 44 | 45 | def get_length(self) -> int: 46 | ... 47 | 48 | def render_headers(self) -> bytes: 49 | ... 50 | 51 | def render_data(self) -> typing.Iterator[bytes]: 52 | ... 53 | 54 | def render(self) -> typing.Iterator[bytes]: 55 | ... 56 | 57 | 58 | 59 | class MultipartStream(SyncByteStream, AsyncByteStream): 60 | """ 61 | Request content as streaming multipart encoded form data. 62 | """ 63 | def __init__(self, data: RequestData, files: RequestFiles, boundary: typing.Optional[bytes] = ...) -> None: 64 | ... 65 | 66 | def iter_chunks(self) -> typing.Iterator[bytes]: 67 | ... 68 | 69 | def iter_chunks_lengths(self) -> typing.Iterator[int]: 70 | ... 71 | 72 | def get_content_length(self) -> int: 73 | ... 74 | 75 | def get_headers(self) -> typing.Dict[str, str]: 76 | ... 77 | 78 | def __iter__(self) -> typing.Iterator[bytes]: 79 | ... 80 | 81 | async def __aiter__(self) -> typing.AsyncIterator[bytes]: 82 | ... 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint and test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | checks: write 11 | contents: write 12 | 13 | jobs: 14 | check-backend: 15 | name: Run test and linters for the back-end 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 5 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | working-directory: backend 23 | 24 | steps: 25 | - name: Check out Git repository 26 | uses: actions/checkout@v3 27 | 28 | - name: Install Poetry 29 | run: pipx install poetry 30 | 31 | - name: Set up Python 3.11 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: "3.11" 35 | cache: "poetry" 36 | 37 | - name: Install dependencies 38 | run: | 39 | poetry install 40 | poetry run prisma generate 41 | 42 | - name: Run ruff formatter 43 | uses: chartboost/ruff-action@v1 44 | with: 45 | args: "format --diff" 46 | src: "./backend" 47 | 48 | - name: Run ruff 49 | uses: chartboost/ruff-action@v1 50 | with: 51 | src: "./backend" 52 | 53 | - name: Run type checker 54 | run: poetry run pyright . 55 | 56 | - name: Run pytest 57 | run: poetry run pytest 58 | 59 | lint-frontend: 60 | name: Run linters for the front-end 61 | runs-on: ubuntu-latest 62 | 63 | defaults: 64 | run: 65 | shell: bash 66 | working-directory: frontend 67 | 68 | steps: 69 | - name: Check out Git repository 70 | uses: actions/checkout@v3 71 | 72 | - name: Set up Node.js 16 73 | uses: actions/setup-node@v3 74 | with: 75 | node-version: "16" 76 | cache: "npm" 77 | cache-dependency-path: frontend/package-lock.json 78 | 79 | - name: Install dependencies 80 | run: npm ci 81 | 82 | - name: Run linters 83 | uses: wearerequired/lint-action@v2 84 | with: 85 | github_token: ${{ secrets.github_token }} 86 | auto_fix: ${{ github.event_name == 'pull_request' }} 87 | eslint: true 88 | eslint_dir: frontend 89 | eslint_extensions: ts,tsx,js,jsx 90 | eslint_auto_fix: true 91 | 92 | - name: Run type checker 93 | run: npm run type-check 94 | -------------------------------------------------------------------------------- /backend/app/plan/plan.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Self 2 | 3 | from pydantic import ( 4 | BaseModel, 5 | validator, # type: ignore 6 | ) 7 | 8 | from app.plan.course import PseudoCourse 9 | from app.plan.validation.curriculum.tree import CurriculumSpec 10 | 11 | CURRENT_PLAN_VERSION = "0.0.2" 12 | MAX_SEMESTERS = 20 13 | MAX_CLASSES = 200 14 | 15 | 16 | class ValidatablePlan(BaseModel): 17 | """ 18 | An academic plan submitted by a user. 19 | Contains all of the courses they have passed and intend to pass. 20 | Also contains all of the context associated with the user (e.g. their choice of 21 | major and minor). 22 | 23 | Including user context here allows plans to be validated without external context, 24 | allowing guests to simulate any plans they want to try out. 25 | """ 26 | 27 | # NOTE: Modifying this class breaks all the JSON stored in the DB 28 | # Currently, we don't handle migrations at all. 29 | # Eventually, we will have to roll our own migration system if we allow 30 | # ValidatablePlans to be exportable/importable. 31 | 32 | # The version of the plan. 33 | # We will probably use this if we move to a `StorablePlan`/`ValidatablePlan` divide. 34 | version: Literal["0.0.2"] 35 | # Classes per semester. 36 | classes: list[list[PseudoCourse]] 37 | # Academic level of the student 38 | level: str | None 39 | # Academic school (facultad) of the student 40 | school: str | None 41 | # Academic program of the student (magisteres, doctorados, etc) 42 | program: str | None 43 | # Career of the student 44 | career: str | None 45 | # The curriculum that the user wants to pursue 46 | # Validate the plan against this curriculum 47 | curriculum: CurriculumSpec 48 | 49 | @validator("classes") # type: ignore 50 | @classmethod 51 | def validate_limits( 52 | cls: type[Self], 53 | classes: list[list[PseudoCourse]], 54 | ) -> list[list[PseudoCourse]]: 55 | if len(classes) > MAX_SEMESTERS: 56 | raise ValueError("too many semesters") 57 | if sum(len(sem) for sem in classes) > MAX_CLASSES: 58 | raise ValueError("too many classes") 59 | return classes 60 | 61 | 62 | class ClassId(BaseModel, frozen=True): 63 | """ 64 | A somewhat stable identifier for a course instance within a plan. 65 | """ 66 | 67 | # The code of the course of this class. 68 | code: str 69 | # The index of the instance of this course. 70 | instance: int 71 | -------------------------------------------------------------------------------- /backend/app/routes/user.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from app import sync 4 | from app.user.auth import ( 5 | login_cas, 6 | require_admin_auth, 7 | require_authentication, 8 | require_mod_auth, 9 | ) 10 | from app.user.info import StudentInfo 11 | from app.user.key import AdminKey, ModKey, Rut, UserKey 12 | 13 | router = APIRouter(prefix="/user") 14 | 15 | 16 | @router.get("/login") 17 | async def authenticate( 18 | next: str | None = None, 19 | ticket: str | None = None, 20 | impersonate_rut: Rut | None = None, 21 | ): 22 | """ 23 | Redirect the browser to this page to initiate authentication. 24 | """ 25 | return await login_cas(next, ticket, impersonate_rut) 26 | 27 | 28 | @router.get("/check") 29 | async def check_auth(user: UserKey = Depends(require_authentication)): 30 | """ 31 | Request succeeds if user authentication was successful. 32 | Otherwise, the request fails with 401 Unauthorized. 33 | """ 34 | return {"message": "Authenticated"} 35 | 36 | 37 | @router.get("/check/mod") 38 | async def check_mod(user: ModKey = Depends(require_mod_auth)): 39 | """ 40 | Request succeeds if user authentication and mod authorization were successful. 41 | Otherwise, the request fails with 401 Unauthorized or 403 Forbidden. 42 | """ 43 | return {"message": "Authenticated with mod access"} 44 | 45 | 46 | @router.get("/check/admin") 47 | async def check_admin(user: AdminKey = Depends(require_admin_auth)): 48 | """ 49 | Request succeeds if user authentication and admin authorization were successful. 50 | Otherwise, the request fails with 401 Unauthorized or 403 Forbidden. 51 | """ 52 | return {"message": "Authenticated with admin access"} 53 | 54 | 55 | @router.get("/info", response_model=StudentInfo) 56 | async def get_student_info(user: UserKey = Depends(require_authentication)): 57 | """ 58 | Get the student info for the currently logged in user. 59 | Requires authentication (!) 60 | This forwards a request to the SIDING service. 61 | """ 62 | return await sync.get_student_info(user) 63 | 64 | 65 | @router.get("/info_for_any_user", response_model=StudentInfo) 66 | async def get_student_info_for_any_user( 67 | user_rut: Rut, 68 | mod: ModKey = Depends(require_mod_auth), 69 | ): 70 | """ 71 | Same functionality as `get_student_info`, but works for any user identified by 72 | their RUT with `user_rut`. 73 | Moderator access is required. 74 | """ 75 | return await sync.get_student_info(mod.as_any_user(user_rut)) 76 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './index.css' 4 | import 'react-toastify/dist/ReactToastify.css' 5 | import * as Sentry from '@sentry/react' 6 | import { 7 | QueryClient, 8 | QueryClientProvider 9 | } from '@tanstack/react-query' 10 | import { OpenAPI } from './client' 11 | import { toastConfig } from './utils/toastConfig' 12 | import App from './app' 13 | 14 | if (import.meta.env.MODE !== 'development') { 15 | // Runs in staging and production 16 | Sentry.init({ 17 | dsn: 'https://deb7a1791e004fd6887189c03b568e8c@o4505547874172928.ingest.sentry.io/4505547928109056', 18 | integrations: [ 19 | new Sentry.BrowserTracing(), 20 | new Sentry.Replay({ 21 | // We don't have any PII shown in the UI 22 | // But if we add some, we should manually add the sentry-mask class 23 | // to the elements that contain it 24 | // see https://docs.sentry.io/platforms/javascript/session-replay/privacy/ 25 | maskAllText: false, 26 | // While this is a good default, this masks the curriculum selectors by default 27 | // And it's not obvious how to unmask them 28 | maskAllInputs: false 29 | }) 30 | ], 31 | 32 | // Set tracesSampleRate to 1.0 to capture 100% 33 | // of transactions for performance monitoring. 34 | tracesSampleRate: 0.7, 35 | 36 | // Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled 37 | tracePropagationTargets: [ 38 | /^https:\/\/mallastest\.ing\.uc\.cl\/api/, 39 | /^https:\/\/plan\.ing\.uc\.cl\/api/, 40 | /^https:\/\/mallastest\.tail6ca5c\.ts\.net\/api/ 41 | ], 42 | 43 | // Capture Replay for 10% of all sessions, 44 | // plus for 100% of sessions with an error 45 | replaysSessionSampleRate: 0.1, 46 | replaysOnErrorSampleRate: 1.0 47 | }) 48 | } 49 | 50 | toastConfig() 51 | const queryClient = new QueryClient() 52 | 53 | const baseUrl = import.meta.env.VITE_BASE_API_URL 54 | if (typeof baseUrl !== 'string') { 55 | throw new Error('VITE_BASE_API_URL environment variable not set during build') 56 | } 57 | OpenAPI.BASE = baseUrl 58 | OpenAPI.TOKEN = async () => { 59 | const token = localStorage.getItem('access-token') 60 | if (token != null) { 61 | return token 62 | } 63 | return '' 64 | } 65 | 66 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 67 | 68 | 69 | 70 | 71 | 72 | ) 73 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.autoSave": "onFocusChange", 3 | // Frontend 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "[typescript]": { 8 | "editor.formatOnSave": false, 9 | "editor.defaultFormatter": "vscode.typescript-language-features" 10 | }, 11 | "[typescriptreact]": { 12 | "editor.formatOnSave": false, 13 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 14 | }, 15 | "[javascript]": { 16 | "editor.formatOnSave": false, 17 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 18 | }, 19 | "[javascriptreact]": { 20 | "editor.formatOnSave": false, 21 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 22 | }, 23 | "eslint.validate": [ 24 | "javascript", 25 | "javascriptreact", 26 | "typescript", 27 | "typescriptreact" 28 | ], 29 | "eslint.format.enable": true, 30 | "eslint.workingDirectories": [ 31 | "./frontend" 32 | ], 33 | "[css]": { 34 | "editor.defaultFormatter": "vscode.css-language-features" 35 | }, 36 | // Backend 37 | "python.defaultInterpreterPath": "/workspaces/planner/backend/.venv/bin/python", 38 | "python.analysis.typeCheckingMode": "strict", 39 | "python.analysis.inlayHints.functionReturnTypes": true, 40 | "python.analysis.inlayHints.variableTypes": false, 41 | "python.terminal.activateEnvInCurrentTerminal": true, 42 | // Black + Ruff 43 | "python.formatting.provider": "none", 44 | "[python]": { 45 | "editor.defaultFormatter": "charliermarsh.ruff", 46 | "editor.formatOnSave": true, 47 | "editor.codeActionsOnSave": { 48 | "source.fixAll": "explicit", 49 | "source.organizeImports": "explicit" 50 | } 51 | }, 52 | "ruff.args": [ 53 | "--config=/workspaces/planner/backend/pyproject.toml" 54 | ], 55 | "python.analysis.extraPaths": [ 56 | "backend/" 57 | ], 58 | // Make Pylance work well with the monorepo 59 | "python.linting.cwd": "backend/", 60 | "python.testing.cwd": "backend/", 61 | "python.testing.pytestEnabled": true, 62 | "python.analysis.diagnosticMode": "workspace", 63 | "python.analysis.stubPath": "backend/typings", 64 | "python.analysis.exclude": [ 65 | "backend/typings/" 66 | ], 67 | "python.analysis.autoImportCompletions": true, 68 | // Ansible 69 | "ansible.python.interpreterPath": "/workspaces/planner/backend/.venv/bin/python", 70 | // Task running 71 | "debug.onTaskErrors": "debugAnyway", 72 | } -------------------------------------------------------------------------------- /frontend/src/pages/planner/utils/Types.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { type ClassId, type CourseRequirementErr, type ApiError, type Major, type Minor, type Title, type CourseDetails, type EquivDetails, type ConcreteId, type EquivalenceId, type CurriculumSpec, type ValidationResult } from '../../../client' 3 | 4 | export interface CoursePos { semester: number, index: number } 5 | export type PseudoCourseId = ConcreteId | EquivalenceId 6 | export type PseudoCourseDetail = CourseDetails | EquivDetails 7 | export type ModalData = { equivalence: EquivDetails | undefined, selector: boolean, semester: number, index?: number } | undefined 8 | export type Cyear = CurriculumSpec['cyear'] 9 | export type Diagnostic = ValidationResult['diagnostics'][number] 10 | export type RequirementExpr = CourseRequirementErr['missing'] 11 | 12 | export interface CurriculumData { 13 | majors: Record 14 | minors: Record 15 | titles: Record 16 | ofCyear: string 17 | ofMajor?: string 18 | } 19 | export const isApiError = (err: any): err is ApiError => { 20 | return err.status !== undefined 21 | } 22 | export const isCancelError = (err: any): boolean => { 23 | return err.name !== undefined && err.name === 'CancelError' 24 | } 25 | 26 | export const isCourseRequirementErr = (diag: any): diag is CourseRequirementErr => { 27 | return diag.kind === 'req' 28 | } 29 | 30 | export type PossibleBlocksList = Record 31 | 32 | export interface CourseValidationDigest { 33 | // Contains the superblock string 34 | // The empty string if no superblock is found 35 | superblock: string 36 | // Contains the errors associated with this course. 37 | errors: Diagnostic[] 38 | // Contains the warnings associated with this course. 39 | warnings: Diagnostic[] 40 | } 41 | export interface SemesterValidationDigest { 42 | // Contains the errors associated with this semester. 43 | errors: Diagnostic[] 44 | // Contains the warnings associated with this semester. 45 | warnings: Diagnostic[] 46 | // The validation digest for each course 47 | courses: CourseValidationDigest[] 48 | } 49 | export interface ValidationDigest { 50 | // Information associated to each semester. 51 | semesters: SemesterValidationDigest[] 52 | // If `true`, the plan is outdated with respect to the courses that the user has taken. 53 | // This is computed from the presence of "outdated" diagnostics. 54 | isOutdated: boolean 55 | } 56 | 57 | export interface PlanDigest { 58 | // Maps `(code, course instance index)` to `(semester, index within semester)` 59 | idToIndex: Record 60 | // Maps `(semester, index within semester)` to `(code, course instance index)` 61 | indexToId: ClassId[][] 62 | } 63 | -------------------------------------------------------------------------------- /backend/app/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging configuration for the application. 3 | Heavily inspired by https://www.pythonbynight.com/blog/sharpen-your-code 4 | """ 5 | 6 | import logging 7 | import sys 8 | from pathlib import Path 9 | 10 | from app.settings import settings 11 | 12 | DATE_FORMAT = "%y-%b-%d %H:%M:%S" 13 | LOGGER_FILE = Path(settings.log_path) # where log is stored 14 | 15 | SIMPLE_FORMAT = logging.Formatter( 16 | "%(levelname)s[%(name)s]: \t%(message)s", 17 | datefmt=DATE_FORMAT, 18 | ) 19 | 20 | log_level = logging.getLevelNamesMapping()[settings.log_level] 21 | 22 | 23 | def production_handlers() -> list[logging.Handler]: 24 | """ 25 | Get a list of log handlers, which redirect log messages to stdout, files, etc... 26 | For production. 27 | """ 28 | 29 | # Stdout 30 | stdout_handler = logging.StreamHandler(sys.stdout) 31 | stdout_handler.setFormatter(SIMPLE_FORMAT) 32 | 33 | return [stdout_handler] 34 | 35 | 36 | def debug_handlers() -> list[logging.Handler]: 37 | """ 38 | Get a list of log handlers, which redirect log messages to stdout, files, etc... 39 | For staging and development. 40 | """ 41 | from rich.logging import RichHandler 42 | 43 | output_file_handler = logging.FileHandler(LOGGER_FILE) 44 | output_file_handler.setFormatter(SIMPLE_FORMAT) 45 | 46 | rich_stdout_handler = RichHandler( 47 | rich_tracebacks=True, 48 | tracebacks_show_locals=False, 49 | show_time=False, 50 | ) 51 | rich_stdout_handler.setFormatter( 52 | logging.Formatter("%(name)s: \t%(message)s", datefmt=DATE_FORMAT), 53 | ) 54 | 55 | return [ 56 | rich_stdout_handler, 57 | output_file_handler, 58 | ] 59 | 60 | 61 | def setup_logger(): 62 | """ 63 | Setup logging depending on the environment type (production, staging, development). 64 | """ 65 | 66 | # For all loggers in external libraries, 67 | for name in logging.root.manager.loggerDict: 68 | external_logger = logging.getLogger(name) 69 | # Remove all handlers 70 | external_logger.handlers = [] 71 | # Enable propagation to the main logger 72 | external_logger.propagate = True 73 | # Force a minimum level of `INFO` 74 | # If we want to debug, we want to debug our code 75 | min_level = logging.INFO 76 | if name == "httpx": 77 | # Special fix for httpx since it spams INFO 78 | min_level = logging.WARNING 79 | external_logger.setLevel(max(min_level, log_level)) 80 | 81 | handlers = ( 82 | production_handlers() if settings.env == "production" else debug_handlers() 83 | ) 84 | 85 | logging.basicConfig( 86 | level=log_level, 87 | handlers=handlers, 88 | ) 89 | -------------------------------------------------------------------------------- /backend/app/plan/course.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Literal 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class EquivalenceId(BaseModel, frozen=True): 7 | is_concrete: Literal[False] = False 8 | # The internal code of this abstract equivalence. 9 | # This code is associated with a list of courses that are equivalent under the 10 | # scope of this equivalence. 11 | # 12 | # NOTE: Equivalence codes and concrete course codes share the same namespace. 13 | # This means that all equivalence codes are different from all course codes. 14 | # (Because equivalence codes are always prefixed by a `?`) 15 | code: str 16 | # How many credits worth of this equivalence does this pseudocourse stand for. 17 | credits: int 18 | 19 | 20 | class ConcreteId(BaseModel, frozen=True): 21 | is_concrete: Literal[True] = True 22 | # The unique course code representing this course. 23 | code: str 24 | # If this course belongs to an equivalence, this field indicates it. 25 | equivalence: EquivalenceId | None 26 | # If this course is a failed course, what course was failed. 27 | failed: str | None = None 28 | 29 | 30 | PseudoCourse = Annotated[ 31 | ConcreteId | EquivalenceId, 32 | Field(discriminator="is_concrete"), 33 | ] 34 | 35 | 36 | def pseudocourse_with_credits(pseudocourse: PseudoCourse, credits: int) -> PseudoCourse: 37 | """ 38 | Create a copy of the given pseudocourse but with a certain amount of credits. 39 | Does a best-effort attempt. If the course is concrete then only the credits of the 40 | associated equivalence can be modified. 41 | """ 42 | if isinstance(pseudocourse, EquivalenceId): 43 | if pseudocourse.credits != credits: 44 | return EquivalenceId(code=pseudocourse.code, credits=credits) 45 | elif pseudocourse.equivalence is not None: 46 | return ConcreteId( 47 | code=pseudocourse.code, 48 | failed=pseudocourse.failed, 49 | equivalence=EquivalenceId( 50 | code=pseudocourse.equivalence.code, 51 | credits=credits, 52 | ), 53 | ) 54 | return pseudocourse 55 | 56 | 57 | def pseudocourse_with_equivalence( 58 | pseudocourse: PseudoCourse, 59 | equiv: EquivalenceId | None, 60 | ) -> PseudoCourse: 61 | """ 62 | Create a copy of `pseudocourse` attached to the given equivalence. 63 | Only creates a copy if the target equivalence is different from the current 64 | equivalence. 65 | """ 66 | if isinstance(pseudocourse, ConcreteId) and pseudocourse.equivalence != equiv: 67 | return ConcreteId( 68 | code=pseudocourse.code, 69 | equivalence=equiv, 70 | failed=pseudocourse.failed, 71 | ) 72 | return pseudocourse 73 | -------------------------------------------------------------------------------- /backend/app/limiting.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | from collections.abc import Callable 4 | 5 | import limits 6 | from fastapi import Depends, HTTPException, Request 7 | 8 | from app.settings import settings 9 | from app.user.auth import require_authentication 10 | from app.user.key import UserKey 11 | 12 | LIMIT_REACHED_ERROR = HTTPException(429, detail="Too many requests") 13 | 14 | # Note: This uses a synchronous connection pool, which is 15 | # why we cannot use the async pool we create in app.redis 16 | # This plausibly has a performance impact, 17 | # but it is not clear how much. 18 | storage = limits.storage.RedisStorage(settings.redis_uri) 19 | 20 | # Ping the Redis server to check if it is alive 21 | if not storage.check(): 22 | warnings.warn( 23 | "Could not connect to Redis, falling back to in-memory storage.", 24 | stacklevel=1, 25 | ) 26 | storage = limits.storage.MemoryStorage() 27 | assert storage.check() 28 | 29 | 30 | # TODO: Limit nicely in the frontend too. 31 | class Limiter: 32 | def __init__(self, limit: str) -> None: 33 | self.limiter = limits.strategies.FixedWindowRateLimiter(storage) 34 | self.rate = limits.parse(limit) 35 | 36 | def check(self, key: str) -> None: 37 | if not self.limiter.hit(self.rate, key): 38 | raise LIMIT_REACHED_ERROR 39 | 40 | 41 | def ratelimit_guest(limit: str) -> Callable[..., None]: 42 | """ 43 | Establish a rate limit for guest users, based on IP addresses. 44 | 45 | Example: 46 | def endpoint(_limited: None = Depends(ratelimit_guest("1/second"))): 47 | pass 48 | 49 | See https://limits.readthedocs.io/en/latest/quickstart.html#ratelimit-string 50 | """ 51 | 52 | limiter = Limiter(limit) 53 | 54 | def check_limit(request: Request): 55 | # IMPORTANT: The validity of this depends on 56 | # a chain of trusted reverse proxy and a properly 57 | # configured ProxyHeadersMiddleware. 58 | assert ( 59 | request.client is not None 60 | ), "Request has no client. This is a security issue." 61 | logging.debug(f"Checking rate limit for {request.client.host}") 62 | logging.debug(f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}") 63 | limiter.check(request.client.host) 64 | 65 | return check_limit 66 | 67 | 68 | def ratelimit_user(limit: str) -> Callable[..., UserKey]: 69 | """ 70 | Establish a rate limit for users, based on RUTs. 71 | This dependency replaces the `require_authorization` dependency! 72 | 73 | Example: 74 | def endpoint(user: UserKey = Depends(ratelimit_user("4/3second"))): 75 | pass 76 | 77 | See https://limits.readthedocs.io/en/latest/quickstart.html#ratelimit-string 78 | """ 79 | 80 | limiter = Limiter(limit) 81 | 82 | def check_limit(user: UserKey = Depends(require_authentication)) -> UserKey: 83 | limiter.check(user.rut) 84 | return user 85 | 86 | return check_limit 87 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "compounds": [ 7 | { 8 | "name": "Launch all + watcher 🚀", 9 | "configurations": [ 10 | "Backend (FastAPI + watcher)", 11 | "CAS Server", 12 | "Frontend (Vite)", 13 | ], 14 | "presentation": { 15 | "order": 1 16 | }, 17 | "stopAll": true 18 | }, 19 | { 20 | "name": "Launch all 🚀", 21 | "configurations": [ 22 | "Backend (FastAPI)", 23 | "CAS Server", 24 | "Frontend (Vite)", 25 | ], 26 | "presentation": { 27 | "order": 2 28 | }, 29 | "stopAll": true 30 | } 31 | ], 32 | "configurations": [ 33 | { 34 | "name": "Backend (FastAPI + watcher)", 35 | "type": "python", 36 | "request": "launch", 37 | "module": "uvicorn", 38 | "args": [ 39 | "app.main:app", 40 | "--reload", 41 | "--log-config=none" 42 | ], 43 | "justMyCode": true, 44 | "cwd": "${workspaceFolder}/backend" 45 | }, 46 | { 47 | "name": "Backend (FastAPI)", 48 | "type": "python", 49 | "request": "launch", 50 | "module": "uvicorn", 51 | "args": [ 52 | "app.main:app", 53 | ], 54 | "justMyCode": true, 55 | "cwd": "${workspaceFolder}/backend", 56 | "preLaunchTask": "Run startup tasks" 57 | }, 58 | { 59 | "name": "Frontend (Vite)", 60 | "command": "npm run dev", 61 | "request": "launch", 62 | "type": "node-terminal", 63 | "cwd": "${workspaceFolder}/frontend", 64 | "preLaunchTask": "Generate client" 65 | }, 66 | { 67 | "name": "CAS Server", 68 | "type": "node-terminal", 69 | "request": "launch", 70 | "command": "npx --yes cas-server-mock --database=$PWD/cas-mock/data/cas-mock-users.json", 71 | "cwd": "${workspaceFolder}", 72 | }, 73 | { 74 | "name": "Open frontend (Chrome + HMR)", 75 | "type": "chrome", 76 | "request": "launch", 77 | "url": "http://localhost:3000/", 78 | "webRoot": "${workspaceRoot}/frontend/src" 79 | }, 80 | { 81 | "name": "Open frontend (Firefox + HMR)", 82 | "type": "firefox", 83 | "request": "launch", 84 | "reAttach": true, 85 | "url": "http://localhost:3000", 86 | "webRoot": "${workspaceFolder}/frontend/", 87 | }, 88 | ] 89 | } -------------------------------------------------------------------------------- /backend/tests/test_tree.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from app.plan.validation.curriculum.tree import MajorCode, MinorCode, TitleCode 3 | from pydantic import BaseModel, ValidationError 4 | 5 | 6 | class CurriculumCodeTest(BaseModel): 7 | """ 8 | Test the CurriculumCode class. 9 | """ 10 | 11 | major_code: MajorCode = MajorCode("M123") 12 | minor_code: MinorCode = MinorCode("N123") 13 | title_code: TitleCode = TitleCode("40001") 14 | 15 | 16 | def test_codes(): 17 | """ 18 | Test validation of major, minor and title codes. 19 | """ 20 | 21 | with pytest.raises(ValidationError): 22 | CurriculumCodeTest(major_code=MajorCode("M1234")) 23 | 24 | with pytest.raises(ValidationError): 25 | CurriculumCodeTest(major_code=MajorCode("L123")) 26 | 27 | with pytest.raises(ValidationError): 28 | CurriculumCodeTest(major_code=MajorCode("DSJDF")) 29 | 30 | with pytest.raises(ValidationError): 31 | CurriculumCodeTest(major_code=MajorCode("N345")) 32 | 33 | with pytest.raises(ValidationError): 34 | CurriculumCodeTest(minor_code=MinorCode("M123")) 35 | 36 | with pytest.raises(ValidationError): 37 | CurriculumCodeTest(minor_code=MinorCode("N5332")) 38 | 39 | with pytest.raises(ValidationError): 40 | CurriculumCodeTest(minor_code=MinorCode("JSDJK")) 41 | 42 | with pytest.raises(ValidationError): 43 | CurriculumCodeTest(title_code=TitleCode("T123")) 44 | 45 | with pytest.raises(ValidationError): 46 | CurriculumCodeTest(title_code=TitleCode("1")) 47 | 48 | with pytest.raises(ValidationError): 49 | CurriculumCodeTest(title_code=TitleCode("FSDSFD")) 50 | 51 | with pytest.raises(ValidationError): 52 | CurriculumCodeTest(title_code=TitleCode("99999999")) 53 | 54 | # Test normal usage 55 | assert str(CurriculumCodeTest(major_code=MajorCode("M123")).major_code) == "M123" 56 | assert str(CurriculumCodeTest(minor_code=MinorCode("N123")).minor_code) == "N123" 57 | assert str(CurriculumCodeTest(title_code=TitleCode("40001")).title_code) == "40001" 58 | 59 | # Test deserialization from string 60 | d = { 61 | "major_code": "M123", 62 | "minor_code": "N123", 63 | "title_code": "40001", 64 | } 65 | parsed_curriculum = CurriculumCodeTest.parse_obj(d) 66 | assert parsed_curriculum.major_code == MajorCode("M123") 67 | assert parsed_curriculum.minor_code == MinorCode("N123") 68 | assert parsed_curriculum.title_code == TitleCode("40001") 69 | # Test serialization to string 70 | parsed_curriculum_dict = parsed_curriculum.dict() 71 | assert parsed_curriculum_dict["major_code"] == "M123" 72 | assert parsed_curriculum_dict["minor_code"] == "N123" 73 | assert parsed_curriculum_dict["title_code"] == "40001" 74 | # Check wrong deserialization 75 | with pytest.raises(ValidationError): 76 | CurriculumCodeTest.parse_obj({"major_code": "M1234"}) 77 | CurriculumCodeTest.parse_obj({"minor_code": "N1234"}) 78 | CurriculumCodeTest.parse_obj({"title_code": "400011"}) 79 | --------------------------------------------------------------------------------