diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 49e0f44..5dd0f28 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -17,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities @@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within @@ -121,8 +121,8 @@ https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). -[homepage]: https://www.contributor-covenant.org - For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. + +[homepage]: https://www.contributor-covenant.org diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 24669b8..3e0d5ee 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,32 +3,31 @@ Thank you for your interest in contributing to auth! This document provides guidelines and instructions for setting up your development environment and contributing to the project. -
πŸ“š Table of Contents - [🀝 Contributing to auth](#-contributing-to-auth) - [🚧 Getting Started](#-getting-started) - [πŸ› οΈ Development Environment Setup](#-development-environment-setup) - - [Prerequisites](#prerequisites) - - [Setting Up Your Environment](#setting-up-your-environment) - - [Set Up Environment Variables](#set-up-environment-variables) - - [Pre-commit Hooks](#pre-commit-hooks) + - [Prerequisites](#prerequisites) + - [Setting Up Your Environment](#setting-up-your-environment) + - [Set Up Environment Variables](#set-up-environment-variables) + - [Pre-commit Hooks](#pre-commit-hooks) - [🧰 Running the Application](#-running-the-application) - [πŸ§ͺ Testing and Code Quality](#-testing-and-code-quality) - - [Pre-commit Hooks](#pre-commit-hooks-1) - - [Linting & Formatting](#linting--formatting) + - [Pre-commit Hooks](#pre-commit-hooks-1) + - [Linting & Formatting](#linting--formatting) - [πŸ§ͺ Running Tests](#-running-tests) - - [Writing Tests](#writing-tests) + - [Writing Tests](#writing-tests) - [πŸš€ Submitting Changes](#-submitting-changes) - - [πŸ”€ Create a Branch](#-create-a-branch) - - [✏️ Make and Commit Changes](#-make-and-commit-changes) - - [πŸ“€ Push and Open a Pull Request](#-push-and-open-a-pull-request) + - [πŸ”€ Create a Branch](#-create-a-branch) + - [✏️ Make and Commit Changes](#-make-and-commit-changes) + - [πŸ“€ Push and Open a Pull Request](#-push-and-open-a-pull-request) - [❓ Need Help?](#-need-help) - [πŸ” Security](#-security) - [✨ Code Style Guide](#-code-style-guide) - - [βœ… General Guidelines](#-general-guidelines) - - [πŸ“ Docstrings & Comments](#-docstrings--comments) + - [βœ… General Guidelines](#-general-guidelines) + - [πŸ“ Docstrings & Comments](#-docstrings--comments) - [🏷️ GitHub Labels](#-github-labels) - [🧩 Feature Suggestions](#-feature-suggestions) - [πŸ“„ License](#-license) @@ -52,14 +51,14 @@ We maintain two deployment environments: The standard workflow for contributing is as follows: 1. Fork the repository on GitHub and clone it to your local machine. -2. Create a new branch for your feature or bug fix. -3. Make your changes and commit them with clear, descriptive messages. -4. Push your branch to your fork on GitHub. -5. Create a Pull Request (PR) against the repository's `dev` branch (not `main`). -6. Wait for review and feedback from the maintainers, address any comments or suggestions. -7. Once approved, your changes will be merged into the `dev` branch and deployed to staging for testing. -8. After all pre-commit checks pass, deployment to staging is triggered automatically. -9. Production deployment is performed manually by authorized maintainers after successful staging validation. +1. Create a new branch for your feature or bug fix. +1. Make your changes and commit them with clear, descriptive messages. +1. Push your branch to your fork on GitHub. +1. Create a Pull Request (PR) against the repository's `dev` branch (not `main`). +1. Wait for review and feedback from the maintainers, address any comments or suggestions. +1. Once approved, your changes will be merged into the `dev` branch and deployed to staging for testing. +1. After all pre-commit checks pass, deployment to staging is triggered automatically. +1. Production deployment is performed manually by authorized maintainers after successful staging validation. > [!WARNING] > Please note that you will not be able to push directly to either the `dev` or `main` branches of the repository. All @@ -82,12 +81,14 @@ projects. ### Setting Up Your Environment 1. **Create and activate a virtual environment:** + ```bash uv venv --python 3.11 source .venv/bin/activate ``` -2. **Install dependencies:** +1. **Install dependencies:** + ```bash uv sync --all-extras ``` @@ -95,11 +96,12 @@ projects. ### Set Up Environment Variables 1. **Copy the example environment file to create your own:** + ```bash cp .env.example .env ``` -2. **Configure your test credentials:** +1. **Configure your test credentials:** Open the `.env` file and replace all `` placeholders with your actual test user details. Each variable has been documented in the `.env.example` file for clarity. @@ -127,13 +129,13 @@ suite automatically before every commit. The following checks are enforced: -* βœ… `ruff` for linting and formatting (with auto-fix) -* βœ… `blacken-docs` to format code blocks inside Markdown files -* βœ… `pyupgrade` to upgrade syntax to Python 3.9+ -* βœ… `end-of-file-fixer`, `trailing-whitespace`, `check-yaml`, `check-toml`, `requirements-txt-fixer` for formatting -* βœ… `name-tests-test` to enforce test naming conventions -* βœ… `debug-statements` to prevent committed `print()` or `pdb` -* βœ… A local `pytest` hook that runs the full test suite +- βœ… `ruff` for linting and formatting (with auto-fix) +- βœ… `blacken-docs` to format code blocks inside Markdown files +- βœ… `pyupgrade` to upgrade syntax to Python 3.9+ +- βœ… `end-of-file-fixer`, `trailing-whitespace`, `check-yaml`, `check-toml`, `requirements-txt-fixer` for formatting +- βœ… `name-tests-test` to enforce test naming conventions +- βœ… `debug-statements` to prevent committed `print()` or `pdb` +- βœ… A local `pytest` hook that runs the full test suite > [!WARNING] > You will not be able to commit code that fails these checks. @@ -168,10 +170,10 @@ uv run pytest --cov ### Writing Tests -* Write tests for all new features and bug fixes -* Place them in the `tests/` directory -* Name your test files and functions with the `test_` prefix (required by `pytest` and validated by pre-commit) -* Keep test cases small, meaningful, and well-named +- Write tests for all new features and bug fixes +- Place them in the `tests/` directory +- Name your test files and functions with the `test_` prefix (required by `pytest` and validated by pre-commit) +- Keep test cases small, meaningful, and well-named ## πŸš€ Submitting Changes @@ -198,7 +200,7 @@ git commit -m "fix: resolve token expiry issue" Use [Conventional Commits](https://www.conventionalcommits.org/) to keep commit history consistent: | Type | Use for… | -|-------------|------------------------------------------------| +| ----------- | ---------------------------------------------- | | `feat:` | New features | | `fix:` | Bug fixes | | `docs:` | Documentation changes | @@ -210,18 +212,19 @@ Use [Conventional Commits](https://www.conventionalcommits.org/) to keep commit ### πŸ“€ Push and Open a Pull Request 1. Push your branch to your fork: + ```bash git push origin your-feature-name ``` -2. Open a Pull Request (PR) on GitHub targeting the `dev` branch. +1. Open a Pull Request (PR) on GitHub targeting the `dev` branch. -3. In your PR: +1. In your PR: - * Use a clear and descriptive title - * Include a summary of your changes - * Link any related issues using `Closes #issue-number` - * Add screenshots, terminal output, or examples if relevant + - Use a clear and descriptive title + - Include a summary of your changes + - Link any related issues using `Closes #issue-number` + - Add screenshots, terminal output, or examples if relevant After your PR is merged into `dev`, all `pre-commit` checks will run automatically. If they pass, deployment to staging is triggered. The maintainers will review your PR, provide feedback, and may request changes. Once approved, your PR will be merged @@ -233,12 +236,12 @@ production which is manually trigerred by authorized maintainers. If you get stuck or have questions: 1. Check the [README.md](../README.md) for setup and usage info. -2. Review [open issues](https://github.com/pesu-dev/auth/issues) +1. Review [open issues](https://github.com/pesu-dev/auth/issues) or [pull requests](https://github.com/pesu-dev/auth/pulls) to see if someone else encountered the same problem. -3. Reach out to the maintainers on PESU Discord. - - Use the `#pesu-auth` channel for questions related to this repository. - - Search for existing discussions before posting. -4. Open a new issue if you're facing something new or need clarification. +1. Reach out to the maintainers on PESU Discord. + - Use the `#pesu-auth` channel for questions related to this repository. + - Search for existing discussions before posting. +1. Open a new issue if you're facing something new or need clarification. ## πŸ” Security @@ -253,18 +256,18 @@ To keep the codebase clean and maintainable, please follow these conventions: ### βœ… General Guidelines -* Write clean, readable code -* Use meaningful variable and function names -* Avoid large functions; keep logic modular and composable -* Use Python 3.11+ syntax when appropriate (e.g., `match`, `|` union types) -* Keep imports sorted and remove unused ones (handled automatically via `ruff`) +- Write clean, readable code +- Use meaningful variable and function names +- Avoid large functions; keep logic modular and composable +- Use Python 3.11+ syntax when appropriate (e.g., `match`, `|` union types) +- Keep imports sorted and remove unused ones (handled automatically via `ruff`) ### πŸ“ Docstrings & Comments -* Add docstrings to all public functions, classes, and modules -* Use [Google-style docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) (or +- Add docstrings to all public functions, classes, and modules +- Use [Google-style docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) (or consistent alternatives) -* Write comments when logic is non-obvious and avoid restating the code +- Write comments when logic is non-obvious and avoid restating the code Example: @@ -289,52 +292,52 @@ each label means: ### πŸ§‘β€πŸ’» Contribution Level | Label | Description | -|--------------------|---------------------------------------------------------------| +| ------------------ | ------------------------------------------------------------- | | `good first issue` | 🟒 Simple, well-scoped tasks good for first-time contributors | | `help wanted` | 🟑 Maintainers are actively seeking help on this issue | ### 🐞 Bug & Error Handling | Label | Description | -|-------------|-------------------------------------------------------------| +| ----------- | ----------------------------------------------------------- | | `bug` | πŸ”΄ A defect or unexpected behavior in the application | | `invalid` | 🚫 The issue/PR is not valid or based on a misunderstanding | -| `wontfix` | ❌ The issue is acknowledged but will not be fixed | +| `wontfix` | ❌ The issue is acknowledged but will not be fixed | | `duplicate` | πŸ“‘ This issue or PR duplicates an existing one | ### ✨ Feature Development | Label | Description | -|---------------|---------------------------------------------------------| +| ------------- | ------------------------------------------------------- | | `enhancement` | 🟒 A request or proposal for improvement or new feature | | `feature` | 🌟 Work related to adding a new capability | -| `question` | ❓ Request for clarification or discussion | +| `question` | ❓ Request for clarification or discussion | ### πŸ“š Documentation | Label | Description | -|-----------------|------------------------------------------------------| +| --------------- | ---------------------------------------------------- | | `documentation` | πŸ“˜ Updates to README, docstrings, or inline comments | ### πŸ§ͺ Testing & CI/CD | Label | Description | -|-------------------|-------------------------------------------------------------------| +| ----------------- | ----------------------------------------------------------------- | | `tests and ci/cd` | πŸ§ͺ Changes or issues related to testing or continuous integration | ### πŸ”’ Authentication & Core | Label | Description | -|-------------------|-----------------------------------------------------------| +| ----------------- | --------------------------------------------------------- | | `authentication` | πŸ” Login, CSRF, token handling, error flows | | `pesuacademy` | πŸŽ“ PESUAcademy client, authentication, and scraping logic | -| `student profile` | πŸ§‘β€πŸŽ“ HTML parsing & profile field extraction logic | +| `student profile` | πŸ§‘β€πŸŽ“ HTML parsing & profile field extraction logic | ### 🧠 Meta / Organization -| Label | Description | -|--------------|-----------------------------------------------------| -| `api` | βš™οΈ Core FastAPI application and route handlers | +| Label | Description | +| ------------ | -------------------------------------------------- | +| `api` | βš™οΈ Core FastAPI application and route handlers | | `discussion` | πŸ—£οΈ Open-ended conversation about project direction | > [!NOTE] @@ -346,8 +349,8 @@ each label means: If you want to propose a new feature: 1. Check if it already exists in [issues](https://github.com/pesu-dev/auth/issues) -2. Open a new issue using the **"Feature Request"** template if available -3. Clearly explain the use case, proposed solution, and any relevant context +1. Open a new issue using the **"Feature Request"** template if available +1. Clearly explain the use case, proposed solution, and any relevant context ## πŸ“„ License diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b4c6662..9c2b4e9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,12 +12,10 @@ Please provide a concise summary of the changes: - What problem does it solve, or what feature does it add? - Any relevant motivation, background, or context? - > ℹ️ **Fixes / Related Issues** > Fixes: #100 > Related: #001 - ## 🧱 Type of Change > *Please indicate the type of changes introduced in your PR. Anything left unchecked will be assumed to be non-relevant* @@ -35,7 +33,6 @@ Please provide a concise summary of the changes: - [ ] πŸ”’ Security fix – Addresses auth/session/data validation vulnerabilities - [ ] 🧰 Dependency update – Updates libraries in `requirements.txt`, `pyproject.toml` - ## πŸ§ͺ How Has This Been Tested? > *Please indicate how you tested your changes. Completing all the relevant items on this list is mandatory* @@ -51,7 +48,6 @@ Please provide a concise summary of the changes: > - Python: (e.g., `3.12` via `uv`) > - [ ] Docker build tested - ## βœ… Checklist > *Please indicate the work items you have carried out. Completing all the relevant items on this list is mandatory. Anything left unchecked will be assumed to be non-relevant* @@ -70,7 +66,6 @@ Please provide a concise summary of the changes: - [ ] I've tested across multiple environments (if applicable) - [ ] Benchmarks still meet expected performance (`scripts/benchmark/benchmark_requests.py`) - ## πŸ› οΈ Affected API Behaviour > *Please indicate the areas affected by changes introduced in your PR* @@ -78,33 +73,31 @@ Please provide a concise summary of the changes: - [ ] `app/app.py` – Modified `/authenticate` route logic - [ ] `app/pesu.py` – Updated scraping or authentication handling - ### 🧩 Models -* [ ] `app/models/request.py` – Input validation or request schema changes -* [ ] `app/models/response.py` – Authentication response formatting -* [ ] `app/models/profile.py` – Profile extraction logic +- [ ] `app/models/request.py` – Input validation or request schema changes +- [ ] `app/models/response.py` – Authentication response formatting +- [ ] `app/models/profile.py` – Profile extraction logic ### 🐳 DevOps & Config -* [ ] `Dockerfile` – Changes to base image or build process -* [ ] `.github/workflows/*.yaml` – CI/CD pipeline or deployment updates -* [ ] `pyproject.toml` / `requirements.txt` – Dependency version changes -* [ ] `.pre-commit-config.yaml` – Linting or formatting hook changes - +- [ ] `Dockerfile` – Changes to base image or build process +- [ ] `.github/workflows/*.yaml` – CI/CD pipeline or deployment updates +- [ ] `pyproject.toml` / `requirements.txt` – Dependency version changes +- [ ] `.pre-commit-config.yaml` – Linting or formatting hook changes ### πŸ“Š Benchmarks & Analysis -* [ ] `scripts/benchmark_auth.py` – Performance or latency measurement changes -* [ ] `scripts/analyze_benchmark.py` – Benchmark result analysis changes -* [ ] `scripts/run_tests.py` – Custom test runner logic or behavior updates - +- [ ] `scripts/benchmark_auth.py` – Performance or latency measurement changes +- [ ] `scripts/analyze_benchmark.py` – Benchmark result analysis changes +- [ ] `scripts/run_tests.py` – Custom test runner logic or behavior updates ## πŸ“Έ Screenshots / API Demos (if applicable) > *Add any visual evidence that supports your changes. MANDATORY for breaking changes.* > > *Examples:* +> > - *Terminal output from a successful `curl` request (redact sensitive data)* > - *Screenshots of Postman/Bruno results* > - *GIF of the endpoint working in a browser* @@ -115,6 +108,7 @@ Please provide a concise summary of the changes: > *Use this space to add any final context or implementation caveats.* > > *Examples:* +> > - *Edge cases or limitations to be aware of* > - *Follow-up work or tech debt to track* > - *Known compatibility issues (e.g., with certain Python versions)* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e73d136..4e55174 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,13 @@ repos: - id: name-tests-test args: [ '--pytest-test-first' ] + - repo: https://github.com/hukkin/mdformat + rev: 0.7.22 + hooks: + - id: mdformat + additional_dependencies: + - mdformat-gfm + - repo: local hooks: - id: pytest diff --git a/README.md b/README.md index bc28d0d..f4b364b 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ returns the user's profile information. No personal data is stored. ## PESUAuth LIVE Deployment -* You can access the PESUAuth API endpoints [here](https://pesu-auth.onrender.com/). -* You can view the health status of the API on the [PESUAuth Health Dashboard](https://xzlk85cp.status.cron-job.org/). +- You can access the PESUAuth API endpoints [here](https://pesu-auth.onrender.com/). +- You can view the health status of the API on the [PESUAuth Health Dashboard](https://xzlk85cp.status.cron-job.org/). #### API Status @@ -46,27 +46,30 @@ following commands to start the API. 1. Build the Docker image either from the source code or pull the pre-built image from Docker Hub. - 1. You can build the Docker image from the source code by running the following command in the root directory of - the repository. - ```bash - docker build . --tag pesu-auth - ``` + 1. You can build the Docker image from the source code by running the following command in the root directory of + the repository. - 2. You can also pull the pre-built Docker image - from [Docker Hub](https://hub.docker.com/repository/docker/pesudev/pesu-auth/general) by running the - following command: - ```bash - docker pull pesudev/pesu-auth:latest - ``` + ```bash + docker build . --tag pesu-auth + ``` -2. Run the Docker container - ```bash - docker run --name pesu-auth -d -p 5000:5000 pesu-auth - # If you pulled the pre-built image, use the following command instead: - docker run --name pesu-auth -d -p 5000:5000 pesudev/pesu-auth:latest - ``` + 1. You can also pull the pre-built Docker image + from [Docker Hub](https://hub.docker.com/repository/docker/pesudev/pesu-auth/general) by running the + following command: -3. Access the API at `http://localhost:5000/` + ```bash + docker pull pesudev/pesu-auth:latest + ``` + +1. Run the Docker container + + ```bash + docker run --name pesu-auth -d -p 5000:5000 pesu-auth + # If you pulled the pre-built image, use the following command instead: + docker run --name pesu-auth -d -p 5000:5000 pesudev/pesu-auth:latest + ``` + +1. Access the API at `http://localhost:5000/` ### Running without Docker @@ -75,25 +78,27 @@ installed on your system. We recommend using a package manager like [`uv`](https dependencies. 1. Create a virtual environment using and activate it. Then, install the dependencies using the following commands. - ```bash - uv venv --python=3.11 - source .venv/bin/activate - uv sync - ``` -2. Run the API using the following command. - ```bash - uv run python -m app.app - ``` + ```bash + uv venv --python=3.11 + source .venv/bin/activate + uv sync + ``` + +1. Run the API using the following command. + + ```bash + uv run python -m app.app + ``` -3. Access the API as previously mentioned on `http://localhost:5000/` +1. Access the API as previously mentioned on `http://localhost:5000/` ## How to use the PESUAuth API The API provides multiple endpoints for authentication, documentation, and monitoring. | **Endpoint** | **Method** | **Description** | -|-----------------|------------|--------------------------------------------------------| +| --------------- | ---------- | ------------------------------------------------------ | | `/` | `GET` | Serves the interactive API documentation (Swagger UI). | | `/authenticate` | `POST` | Authenticates a user using their PESU credentials. | | `/health` | `GET` | A health check endpoint to monitor the API's status. | @@ -106,12 +111,13 @@ object, with the user's profile information if requested. #### Request Parameters -| **Parameter** | **Optional** | **Type** | **Default** | **Description** | -|---------------|--------------|-------------|-------------|-------------------------------------------------------------------------------------------------| -| `username` | No | `str` | | The user's SRN or PRN | -| `password` | No | `str` | | The user's password | -| `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | -| `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | +| **Parameter** | **Optional** | **Type** | **Default** | **Description** | +| ----------------------------- | ------------ | ----------- | ----------- | ----------------------------------------------------------------------------------------------- | +| `username` | No | `str` | | The user's SRN or PRN | +| `password` | No | `str` | | The user's password | +| `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | +| `know_your_class_and_section` | Yes | `boolean` | `False` | Whether to fetch data from PESU's "Know Your Class and Section" information | +| `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | #### Response Object @@ -119,12 +125,13 @@ On authentication, it returns the following parameters in a JSON object. If the profile data was requested, the response's `profile` key will store a dictionary with a user's profile information. **On an unsuccessful sign-in, this field will not exist**. -| **Field** | **Type** | **Description** | -|-------------|-----------------|--------------------------------------------------------------------------| -| `status` | `boolean` | A flag indicating whether the overall request was successful | -| `profile` | `ProfileObject` | A nested map storing the profile information, returned only if requested | -| `message` | `str` | A message that provides information corresponding to the status | -| `timestamp` | `datetime` | A timezone offset timestamp indicating the time of authentication | +| **Field** | **Type** | **Description** | +| ----------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- | +| `status` | `boolean` | A flag indicating whether the overall request was successful | +| `profile` | `ProfileObject` | A nested map storing the profile information, returned only if requested | +| `know_your_class_and_section` | `KnowYourClassAndSectionObject` | A nested map storing the profile information from PESU's "Know Your Class and Section" endpoint | +| `message` | `str` | A message that provides information corresponding to the status | +| `timestamp` | `datetime` | A timezone offset timestamp indicating the time of authentication | ##### `ProfileObject` @@ -132,7 +139,7 @@ This object contains the user's profile information, which is returned only if t If the authentication fails, this field will not be present in the response. | **Field** | **Description** | -|---------------|--------------------------------------------------------| +| ------------- | ------------------------------------------------------ | | `name` | Name of the user | | `prn` | PRN of the user | | `srn` | SRN of the user | @@ -145,6 +152,21 @@ If the authentication fails, this field will not be present in the response. | `campus_code` | The integer code of the campus (1 for RR and 2 for EC) | | `campus` | Abbreviation of the user's campus name | +#### `KnowYourClassAndSectionObject` + +| **Field** | **Description** | +| ---------------- | ------------------------------------------------------------------------ | +| `prn` | PRN of the user | +| `srn` | SRN of the user | +| `name` | Name of the user | +| `semester` | Current semester that the user is in | +| `section` | Section of the user | +| `cycle` | Physics Cycle or Chemistry Cycle, if the user is in first year | +| `department` | Abbreviation of the branch along with the campus the user is studying in | +| `branch` | Abbreviation of the branch that the user is pursuing | +| `institute_name` | The name of the campus that the user is studying in | +| `error` | The error name and stack trace, if an error occurs | + ### `/health` This endpoint can be used to check the health of the API. It's useful for monitoring and uptime checks. This endpoint @@ -152,10 +174,10 @@ does not take any request parameters. #### Response Object -| **Field** | **Type** | **Description** | -|-----------|------------|-------------------------------------------------------------------| -| `status` | `str` | `true` if healthy, `false` if there was an error | -| `message` | `str` | "ok" if healthy, error message otherwise | +| **Field** | **Type** | **Description** | +| ----------- | -------- | ----------------------------------------------------------------- | +| `status` | `str` | `true` if healthy, `false` if there was an error | +| `message` | `str` | "ok" if healthy, error message otherwise | | `timestamp` | `string` | A timezone offset timestamp indicating the time of authentication | ### `/readme` @@ -177,6 +199,7 @@ data = { "username": "your SRN or PRN here", "password": "your password here", "profile": True, # Optional, defaults to False + 'know_your_class_and_section': True, # Optional, defaults to False } response = requests.post("http://localhost:5000/authenticate", json=data) @@ -202,6 +225,17 @@ print(response.json()) "campus": "RR" }, "message": "Login successful.", + "know_your_class_and_section": { + "prn": "PES1201800001", + "srn": "PES1201800001", + "name": "JOHNNY BLAZE", + "semester": "Sem-8", + "section": "Section F", + "cycle": "NA", + "department": "CSE(EC Campus)", + "branch": "CSE", + "institute_name": "PES University (Electronic City)" + }, "timestamp": "2024-07-28 22:30:10.103368+05:30" } ``` diff --git a/app/app.py b/app/app.py index 91e8ecc..5fc5ece 100644 --- a/app/app.py +++ b/app/app.py @@ -194,6 +194,7 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) username = payload.username password = payload.password profile = payload.profile + know_your_class_and_section = payload.know_your_class_and_section fields = payload.fields # Authenticate the user @@ -204,6 +205,7 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) username=username, password=password, profile=profile, + know_your_class_and_section=know_your_class_and_section, fields=fields, ), ) diff --git a/app/docs/authenticate.py b/app/docs/authenticate.py index 33d6521..28dbdea 100644 --- a/app/docs/authenticate.py +++ b/app/docs/authenticate.py @@ -23,6 +23,16 @@ "profile": True, }, }, + "auth_with_kycas": { + "summary": 'Authentication with "Know Your Class and Section" endpoint', + "description": 'Authentication with "Know Your Class and Section" data', + "value": { + "username": "PES1201800001", + "password": "mySecurePassword123", + "profile": True, + "know_your_class_and_section": True, + }, + }, "phone_auth_selective_fields": { "summary": "Authentication with Selected Fields", "description": "Authentication using username and requesting specific profile data fields", @@ -74,6 +84,38 @@ }, }, }, + "authentication_with_kycas": { + "summary": 'Authentication with "Know Your Class and Section endpoint"', + "value": { + "status": True, + "message": "Login successful.", + "timestamp": "2024-07-28T22:30:10.103368+05:30", + "profile": { + "name": "John Doe", + "prn": "PESXXYYZZZZZ", + "srn": "PESXXUGYYZZZ", + "program": "Bachelor of Technology", + "branch": "Computer Science and Engineering", + "semester": "2", + "section": "C", + "email": "johndoe@gmail.com", + "phone": "1234567890", + "campus_code": 1, + "campus": "RR", + }, + "know_your_class_and_section": { + "prn": "PESXXYYZZZZZ", + "srn": "PESXXUGYYZZZ", + "name": "John Doe", + "semester": "Sem-X", + "section": "Section X", + "cycle": "NA", + "department": "Computer Science and Engineering", + "branch": "CSE", + "institute_name": "PES University", + }, + }, + }, "authentication_with_selected_fields": { "summary": "Authentication with Selected Fields", "value": { @@ -167,6 +209,14 @@ "timestamp": "2024-07-28T22:30:10.103368+05:30", }, }, + "kycas_fetch_error": { + "summary": '"Know Your Class and Section" endpoint fetching failed', + "value": { + "status": False, + "message": "Failed to fetch Know Your Class and Section data from PESU Academy.", + "timestamp": "2024-07-28T22:30:10.103368+05:30", + }, + }, } } }, diff --git a/app/exceptions/authentication.py b/app/exceptions/authentication.py index 03833c8..62560d8 100644 --- a/app/exceptions/authentication.py +++ b/app/exceptions/authentication.py @@ -33,3 +33,14 @@ class ProfileParseError(PESUAcademyError): def __init__(self, message: str = "Failed to parse student profile page from PESU Academy.") -> None: """Initialize the ProfileParseError with a custom message.""" super().__init__(message, status_code=422) + + +class KYCASFetchError(PESUAcademyError): + """Raised when "Know Your Class and Section" data could not be fetched from PESU Academy.""" + + def __init__( + self, + message: str = 'Failed to fetch "Know Your Class and Section" data from PESU Academy.', + ) -> None: + """Initialize the "Know Your Class and Section" FetchError with a custom message.""" + super().__init__(message, status_code=502) diff --git a/app/models/__init__.py b/app/models/__init__.py index c06043f..3791feb 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ """Custom models for the PESUAuth API.""" +from .kycas import KYCASModel as KYCASModel from .profile import ProfileModel as ProfileModel from .request import RequestModel as RequestModel from .response import ResponseModel as ResponseModel diff --git a/app/models/kycas.py b/app/models/kycas.py new file mode 100644 index 0000000..4ad283a --- /dev/null +++ b/app/models/kycas.py @@ -0,0 +1,64 @@ +"""Model representing the "Know Your Class and Section" data returned after successful authentication.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class KYCASModel(BaseModel): + """Model representing the "Know Your Class and Section" data.""" + + model_config = ConfigDict(strict=True) + + prn: str | None = Field( + None, + title="PRN", + description="PRN of the user.", + json_schema_extra={"example": "PESXXYYZZZZZ"}, + ) + srn: str | None = Field( + None, + title="SRN", + description="SRN of the user.", + json_schema_extra={"example": "PESXXUGYYZZZ"}, + ) + name: str | None = Field( + None, + title="Name", + description="Full name of the user.", + json_schema_extra={"example": "John Doe"}, + ) + semester: str | None = Field( + None, + title="Semester", + description="Semester the user belongs to.", + json_schema_extra={"example": "Sem-X"}, + ) + section: str | None = Field( + None, + title="Section", + description="Section the user belongs to.", + json_schema_extra={"example": "Section X"}, + ) + cycle: str | None = Field( + None, + title="Cycle", + description="Cycle the user belongs to.", + json_schema_extra={"example": "NA"}, + ) + department: str | None = Field( + None, + title="Department", + description="Department the user belongs to.", + json_schema_extra={"example": "Computer Science and Engineering"}, + ) + branch: str | None = Field( + None, + title="Branch", + description="Abbreviation of the branch that the user is pursuing.", + json_schema_extra={"example": "CSE"}, + ) + institute_name: str | None = Field( + None, + title="Institute Name", + description="Institute the user belongs to.", + json_schema_extra={"example": "PES University"}, + ) diff --git a/app/models/profile.py b/app/models/profile.py index 7b47b96..2f59826 100644 --- a/app/models/profile.py +++ b/app/models/profile.py @@ -43,7 +43,7 @@ class ProfileModel(BaseModel): semester: str | None = Field( None, title="Semester", - description="Current semester of the user.", + description="Current semester the user is pursuing.", json_schema_extra={"example": "2"}, ) section: str | None = Field( diff --git a/app/models/request.py b/app/models/request.py index eba390c..f0b1f83 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -33,6 +33,16 @@ class RequestModel(BaseModel): json_schema_extra={"example": True}, ) + know_your_class_and_section: bool = Field( + False, + title="Know Your Class and Section Flag", + description=( + "Whether to fetch the user's class and section information from the " + '"Know Your Class and Section" endpoint.' + ), + json_schema_extra={"example": True}, + ) + fields: list[Literal[*PESUAcademy.DEFAULT_FIELDS]] | None = Field( None, title="Profile Fields", diff --git a/app/models/response.py b/app/models/response.py index 686e105..e7a5b6a 100644 --- a/app/models/response.py +++ b/app/models/response.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field -from app.models import ProfileModel +from app.models import KYCASModel, ProfileModel class ResponseModel(BaseModel): @@ -38,3 +38,12 @@ class ResponseModel(BaseModel): title="User Profile Data", description="The user's profile data returned only if authentication succeeds and profile data was requested.", ) + + know_your_class_and_section: KYCASModel | None = Field( + None, + title='"Know Your Class and Section" Data', + description=( + "The user's class and section data from the " + '"Know Your Class and Section" endpoint returned only if authentication succeeds.' + ), + ) diff --git a/app/pesu.py b/app/pesu.py index 2f1941a..96dcf63 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -12,6 +12,7 @@ from app.exceptions.authentication import ( AuthenticationError, CSRFTokenError, + KYCASFetchError, ProfileFetchError, ProfileParseError, ) @@ -46,6 +47,10 @@ class PESUAcademy: "phone", "campus_code", "campus", + "semester", + "cycle", + "department", + "institute_name", ] PROFILE_PAGE_HEADER_TO_KEY_MAP = { @@ -58,6 +63,18 @@ class PESUAcademy: "Section": "section", } + KYCAS_HEADER_TO_KEY_MAP = { + "PRN": "prn", + "SRN": "srn", + "Name": "name", + "Class": "semester", + "Section": "section", + "Cycle": "cycle", + "Department": "department", + "Branch": "branch", + "Institute Name": "institute_name", + } + def __init__(self) -> None: """Initialize the PESUAcademy class.""" self._csrf_token: str | None = None @@ -254,11 +271,93 @@ async def get_profile_information( return profile + async def get_know_your_class_and_section( + self, + client: httpx.AsyncClient, + csrf_token: str, + username: str, + ) -> dict[str, Any]: + """Get the class and section information of the user from the "Know Your Class and Section" endpoint. + + Args: + client (httpx.AsyncClient): The authenticated HTTP client to use for making requests. + csrf_token (str): The authenticated CSRF token. + username (str): The username of the user, usually their SRN or PRN. + + Returns: + dict[str, Any]: A dictionary containing the user's class and section information. + """ + logging.info(f'Fetching class and section data for user={username} from "Know Your Class and Section" page...') + kycas_url = "https://www.pesuacademy.com/Academy/a/getStudentClassInfo" + kycas_data = {"controllerMode": "370", "actionType": "174", "loginId": username} + kycas_headers = { + "origin": "https://www.pesuacademy.com", + "referer": "https://www.pesuacademy.com/Academy/", + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "x-csrf-token": csrf_token, + "x-requested-with": "XMLHttpRequest", + } + + try: + response = await client.post(kycas_url, data=kycas_data, headers=kycas_headers) + except Exception: + raise KYCASFetchError( + f'Failed to send "Know Your Class and Section" request to PESU Academy for user={username}.', + ) + + if response.status_code != 200: + raise KYCASFetchError( + f'Failed to fetch "Know Your Class and Section" data from PESU Academy for user={username}. ' + f"Received status code {response.status_code}.", + ) + + soup = await asyncio.to_thread(HTMLParser, response.text) + kycas: dict[str, Any] = {} + + table = soup.css_first("table") + if not table: + raise KYCASFetchError( + f'Could not find "Know Your Class and Section" table in the response for user={username}.', + ) + + headers = [th.text(strip=True) for th in table.css("thead th")] + if not headers: + raise KYCASFetchError( + f'Could not find "Know Your Class and Section" table headers in the response for user={username}.', + ) + + row = table.css_first("tbody tr") + if not row: + raise KYCASFetchError( + f'Could not find "Know Your Class and Section" data row in the response for user={username}.', + ) + + cells = [td.text(strip=True) for td in row.css("td")] + + if len(headers) != len(cells): + raise KYCASFetchError( + f'Mismatch between "Know Your Class and Section" table headers ({len(headers)}) ' + f"and cells ({len(cells)}) for user={username}.", + ) + + for header, cell_value in zip(headers, cells): + if mapped_key := self.KYCAS_HEADER_TO_KEY_MAP.get(header): + kycas[mapped_key] = cell_value + + if not kycas: + raise KYCASFetchError( + f'No "Know Your Class and Section" data could be extracted for user={username}.', + ) + + logging.info(f'"Know Your Class and Section" data retrieved for user={username}: {kycas}.') + return kycas + async def authenticate( self, username: str, password: str, profile: bool = False, + know_your_class_and_section: bool = False, fields: list[str] | None = None, ) -> dict[str, Any]: """Authenticate the user with the provided username and password. @@ -267,6 +366,8 @@ async def authenticate( username (str): The username of the user, usually their PRN/email/phone number. password (str): The password of the user. profile (bool, optional): Whether to fetch the profile information or not. Defaults to False. + know_your_class_and_section (bool, optional): Whether to fetch from the + "Know Your Class and Section" endpoint or not. Defaults to False. fields (Optional[list[str]], optional): The fields to fetch from the profile. Defaults to None, which means all default fields will be fetched. @@ -333,6 +434,27 @@ async def authenticate( f"Field filtering enabled. Filtered profile data for user={username}: {result['profile']}", ) + if know_your_class_and_section: + logging.info( + f'"Know Your Class and Section" data requested for user={username}. ' + 'Fetching "Know Your Class and Section" data...', + ) + # Fetch the class and section information + result["know_your_class_and_section"] = await self.get_know_your_class_and_section( + client, + csrf_token, + username, + ) + # Filter the fields if field filtering is enabled + if field_filtering: + result["know_your_class_and_section"] = { + key: value for key, value in result["know_your_class_and_section"].items() if key in fields + } + logging.info( + f'Field filtering enabled. Filtered "Know Your Class and Section" data for user={username}: ' + f"{result['know_your_class_and_section']}", + ) + logging.info(f"Authentication process for user={username} completed successfully.") # Close the client and return the result diff --git a/pyproject.toml b/pyproject.toml index d4a330b..ec17b38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ [project.optional-dependencies] dev = [ "matplotlib>=3.10.3", + "mdformat>=1.0.0", + "mdformat-gfm>=1.0.0", "numpy>=2.3.1", "pandas>=2.3.1", "pre-commit>=4.2.0", diff --git a/tests/functional/test_authenticate_functional.py b/tests/functional/test_authenticate_functional.py index c330cf3..12d7b9c 100644 --- a/tests/functional/test_authenticate_functional.py +++ b/tests/functional/test_authenticate_functional.py @@ -166,3 +166,89 @@ async def test_authenticate_invalid_credentials(pesu_academy: PESUAcademy): assert result["status"] is False assert "Invalid username or password" in result["message"] assert "profile" not in result + + +@pytest.mark.secret_required +@pytest.mark.asyncio +async def test_authenticate_with_kycas(pesu_academy: PESUAcademy): + """Test successful authentication with "Know Your Class and Section" data.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + result = await pesu_academy.authenticate( + email, + password, + know_your_class_and_section=True, + fields=None, + ) + assert result["status"] is True + assert "Login successful" in result["message"] + assert "know_your_class_and_section" in result + kycas = result["know_your_class_and_section"] + assert "prn" in kycas or "srn" in kycas + assert "name" in kycas + + +@pytest.mark.secret_required +@pytest.mark.asyncio +async def test_authenticate_with_kycas_and_profile(pesu_academy: PESUAcademy): + """Test authentication requesting both profile and "Know Your Class and Section" data.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + result = await pesu_academy.authenticate( + email, + password, + profile=True, + know_your_class_and_section=True, + fields=None, + ) + assert result["status"] is True + assert "profile" in result + assert "know_your_class_and_section" in result + + +@pytest.mark.secret_required +@pytest.mark.asyncio +async def test_authenticate_with_kycas_field_filtering(pesu_academy: PESUAcademy): + """Test that "Know Your Class and Section" data respects field filtering.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + result = await pesu_academy.authenticate( + email, + password, + know_your_class_and_section=True, + fields=["name", "semester"], + ) + assert result["status"] is True + kycas = result["know_your_class_and_section"] + assert "name" in kycas + assert "semester" in kycas + assert "prn" not in kycas + assert "branch" not in kycas + + +@pytest.mark.secret_required +@pytest.mark.asyncio +async def test_authenticate_without_kycas(pesu_academy: PESUAcademy): + """Test that "Know Your Class and Section" data is NOT returned when not requested.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + result = await pesu_academy.authenticate( + email, + password, + know_your_class_and_section=False, + fields=None, + ) + assert result["status"] is True + assert "know_your_class_and_section" not in result diff --git a/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index 7baca86..ef5f15e 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -354,3 +354,86 @@ def test_unhandled_exception_handler(client): data = response.json() assert data["status"] is False assert data["message"] == "Internal Server Error. Please try again later." + + +def test_integration_authenticate_kycas_wrong_type(client): + """Test that non-boolean know_your_class_and_section is rejected.""" + payload = { + "username": "username", + "password": "password", + "know_your_class_and_section": "true", + } + + response = client.post("/authenticate", json=payload) + assert response.status_code == 400 + data = response.json() + assert data["status"] is False + assert "Could not validate request data" in data["message"] + assert "body.know_your_class_and_section: Input should be a valid boolean" in data["message"] + +@pytest.mark.secret_required +def test_integration_authenticate_with_kycas(client): + """Test successful authentication with "Know Your Class and Section" data.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + payload = { + "username": email, + "password": password, + "know_your_class_and_section": True, + } + + response = client.post("/authenticate", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["status"] is True + assert data["message"] == "Login successful." + assert "know_your_class_and_section" in data + kycas = data["know_your_class_and_section"] + assert "prn" in kycas or "srn" in kycas or "name" in kycas + + +@pytest.mark.secret_required +def test_integration_authenticate_with_profile_and_kycas(client): + """Test successful authentication requesting both profile and "Know Your Class and Section".""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + payload = { + "username": email, + "password": password, + "profile": True, + "know_your_class_and_section": True, + } + + response = client.post("/authenticate", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["status"] is True + assert "profile" in data + assert "know_your_class_and_section" in data + + +@pytest.mark.secret_required +def test_integration_authenticate_kycas_without_requesting(client): + """Test that "Know Your Class and Section" data is NOT returned when know_your_class_and_section is False.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + payload = { + "username": email, + "password": password, + "know_your_class_and_section": False, + } + + response = client.post("/authenticate", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["status"] is True + assert data.get("know_your_class_and_section") is None diff --git a/tests/unit/test_app_unit.py b/tests/unit/test_app_unit.py index a9c8502..6231d06 100644 --- a/tests/unit/test_app_unit.py +++ b/tests/unit/test_app_unit.py @@ -8,7 +8,8 @@ @pytest.fixture def client(): - return TestClient(app, raise_server_exceptions=False) + with TestClient(app, raise_server_exceptions=False) as client: + yield client @patch("app.app.pesu_academy.authenticate") diff --git a/tests/unit/test_pesu.py b/tests/unit/test_pesu.py index 33ed4b0..b284add 100644 --- a/tests/unit/test_pesu.py +++ b/tests/unit/test_pesu.py @@ -5,6 +5,7 @@ from app.exceptions.authentication import ( AuthenticationError, CSRFTokenError, + KYCASFetchError, ProfileFetchError, ProfileParseError, ) @@ -395,7 +396,7 @@ async def test_get_profile_information_no_profile_data(mock_get, mock_html_parse @patch("app.pesu.HTMLParser") @patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.PESUAcademy._extract_and_update_profile", new_callable=AsyncMock) +@patch("app.pesu.PESUAcademy._extract_and_update_profile", new_callable=MagicMock) @pytest.mark.asyncio async def test_get_profile_information_empty_profile_triggers_final_parse_error( mock_extract, @@ -489,3 +490,400 @@ def test_default_fields_is_list(): assert "phone" in PESUAcademy.DEFAULT_FIELDS assert "campus_code" in PESUAcademy.DEFAULT_FIELDS assert "campus" in PESUAcademy.DEFAULT_FIELDS + +@pytest.mark.asyncio +async def test_get_kycas_http_exception(pesu): + """Test that the "Know Your Class and Section" fetch error is raised on request failure.""" + client = AsyncMock() + client.post.side_effect = Exception("Connection error") + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Failed to send "Know Your Class and Section" request' in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_kycas_non_200_status(pesu): + """Test that the "Know Your Class and Section" fetch error is raised on non-200 responses.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 500 + client.post.return_value = mock_response + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert "Received status code 500" in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_no_table(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised when no table is found.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + client.post.return_value = mock_response + + mock_soup = MagicMock() + mock_soup.css_first.return_value = None + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Could not find "Know Your Class and Section" table' in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_no_headers(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised when headers are empty.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "
" + client.post.return_value = mock_response + + mock_table = MagicMock() + mock_table.css.return_value = [] + + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Could not find "Know Your Class and Section" table headers' in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_no_data_row(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised when no row exists.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "
PRN
" + client.post.return_value = mock_response + + mock_th = MagicMock() + mock_th.text.return_value = "PRN" + + mock_table = MagicMock() + mock_table.css.return_value = [mock_th] + mock_table.css_first.return_value = None + + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Could not find "Know Your Class and Section" data row' in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_header_cell_mismatch(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised on malformed rows.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + client.post.return_value = mock_response + + mock_th1 = MagicMock() + mock_th1.text.return_value = "PRN" + mock_th2 = MagicMock() + mock_th2.text.return_value = "SRN" + + mock_td1 = MagicMock() + mock_td1.text.return_value = "PES1201800001" + + mock_row = MagicMock() + mock_row.css.return_value = [mock_td1] + + mock_table = MagicMock() + mock_table.css.return_value = [mock_th1, mock_th2] + mock_table.css_first.return_value = mock_row + + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Mismatch between "Know Your Class and Section" table headers' in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_no_mapped_keys(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised on unknown headers.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + client.post.return_value = mock_response + + mock_th = MagicMock() + mock_th.text.return_value = "UnknownHeader" + + mock_td = MagicMock() + mock_td.text.return_value = "some_value" + + mock_row = MagicMock() + mock_row.css.return_value = [mock_td] + + mock_table = MagicMock() + mock_table.css.return_value = [mock_th] + mock_table.css_first.return_value = mock_row + + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'No "Know Your Class and Section" data could be extracted' in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_kycas_success(pesu): + """Test the happy path: successfully parsing "Know Your Class and Section" data.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PRNSRNNameClassSectionCycleDepartmentBranchInstitute Name
PES2202100984PES2UG21CS310Test UserSem-8Section FNACSE(EC Campus)CSEPES University (Electronic City)
+ """ + client.post.return_value = mock_response + + result = await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + + assert result["prn"] == "PES2202100984" + assert result["srn"] == "PES2UG21CS310" + assert result["name"] == "Test User" + assert result["semester"] == "Sem-8" + assert result["section"] == "Section F" + assert result["cycle"] == "NA" + assert result["department"] == "CSE(EC Campus)" + assert result["branch"] == "CSE" + assert result["institute_name"] == "PES University (Electronic City)" + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") +@pytest.mark.asyncio +async def test_authenticate_passes_kycas_flag(mock_get_kycas, mock_post, mock_get, pesu): + """Test that authenticate calls get_know_your_class_and_section when the flag is set.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "institute_name": "PES University", + } + + result = await pesu.authenticate("testuser", "testpass", profile=False, know_your_class_and_section=True) + + assert result["status"] is True + assert result["know_your_class_and_section"]["semester"] == "Sem-6" + mock_get_kycas.assert_called_once() + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@pytest.mark.asyncio +async def test_authenticate_success_no_kycas(mock_post, mock_get, pesu): + """Test that "Know Your Class and Section" data is NOT returned when not requested.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + result = await pesu.authenticate("user", "pass", know_your_class_and_section=False) + assert result["status"] is True + assert "know_your_class_and_section" not in result + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") +@pytest.mark.asyncio +async def test_authenticate_with_kycas(mock_get_kycas, mock_post, mock_get, pesu): + """Test that "Know Your Class and Section" data is returned when requested.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "institute_name": "PES University", + } + + result = await pesu.authenticate("user", "pass", know_your_class_and_section=True) + + assert result["status"] is True + assert "know_your_class_and_section" in result + assert result["know_your_class_and_section"]["prn"] == "PES1201800001" + assert result["know_your_class_and_section"]["institute_name"] == "PES University" + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") +@pytest.mark.asyncio +async def test_authenticate_with_kycas_field_filtering(mock_get_kycas, mock_post, mock_get, pesu): + """Test that "Know Your Class and Section" data is filtered when field filtering is enabled.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "institute_name": "PES University", + } + + result = await pesu.authenticate( + "user", + "pass", + know_your_class_and_section=True, + fields=["name", "semester"], + ) + + assert result["status"] is True + kycas = result["know_your_class_and_section"] + assert "name" in kycas + assert "semester" in kycas + assert "prn" not in kycas + assert "branch" not in kycas + assert "institute_name" not in kycas + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_profile_information") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") +@pytest.mark.asyncio +async def test_authenticate_with_both_profile_and_kycas( + mock_get_kycas, mock_get_profile, mock_post, mock_get, pesu +): + """Test requesting both profile and "Know Your Class and Section" data simultaneously.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_profile.return_value = { + "name": "John Doe", + "prn": "PES1201800001", + "email": "john@example.com", + } + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "semester": "Sem-6", + "section": "Section A", + } + + result = await pesu.authenticate( + "user", "pass", profile=True, know_your_class_and_section=True + ) + + assert result["status"] is True + assert "profile" in result + assert "know_your_class_and_section" in result + assert result["profile"]["name"] == "John Doe" + assert result["know_your_class_and_section"]["semester"] == "Sem-6" + +def test_kycas_header_to_key_map_is_dict(): + """Test that the "Know Your Class and Section" header map has expected keys.""" + kmap = PESUAcademy.KYCAS_HEADER_TO_KEY_MAP + assert isinstance(kmap, dict) + assert "PRN" in kmap + assert "SRN" in kmap + assert "Name" in kmap + assert "Class" in kmap + assert kmap["Class"] == "semester" + assert "Section" in kmap + assert "Cycle" in kmap + assert "Department" in kmap + assert "Branch" in kmap + assert "Institute Name" in kmap + + +def test_default_fields_includes_kycas_relevant_fields(): + """Test that DEFAULT_FIELDS includes fields relevant to "Know Your Class and Section" filtering.""" + fields = PESUAcademy.DEFAULT_FIELDS + assert "semester" in fields + assert "cycle" in fields + assert "department" in fields + assert "institute_name" in fields diff --git a/tests/unit/test_request_model.py b/tests/unit/test_request_model.py index af0681f..f4baf5a 100644 --- a/tests/unit/test_request_model.py +++ b/tests/unit/test_request_model.py @@ -110,3 +110,42 @@ def test_validate_username_strips_whitespace(): def test_validate_password_strips_whitespace(): model = RequestModel(username="testuser", password=" testpass ") assert model.password == "testpass" + + +def test_validate_know_your_class_and_section_default_false(): + """Test that know_your_class_and_section defaults to False.""" + model = RequestModel(username="testuser", password="testpass") + assert model.know_your_class_and_section is False + + +def test_validate_know_your_class_and_section_true(): + """Test setting know_your_class_and_section to True.""" + model = RequestModel( + username="testuser", + password="testpass", + know_your_class_and_section=True, + ) + assert model.know_your_class_and_section is True + + +def test_validate_know_your_class_and_section_invalid_type(): + """Test that non-boolean types are rejected for know_your_class_and_section.""" + with pytest.raises(ValidationError) as exc_info: + RequestModel( + username="testuser", + password="testpass", + know_your_class_and_section="yes", + ) + assert exc_info.value.errors()[0]["type"] == "bool_type" + assert "Input should be a valid boolean" in str(exc_info.value) + + +def test_validate_know_your_class_and_section_int_rejected(): + """Test that integer types are rejected (strict mode).""" + with pytest.raises(ValidationError) as exc_info: + RequestModel( + username="testuser", + password="testpass", + know_your_class_and_section=1, + ) + assert "Input should be a valid boolean" in str(exc_info.value) diff --git a/uv.lock b/uv.lock index 67543d1..3e47d0a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.12'", @@ -434,6 +434,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload-time = "2024-12-24T18:30:40.019Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "matplotlib" version = "3.10.5" @@ -498,6 +510,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/66/cd29ebc7f6c0d2a15d216fb572573e8fc38bd5d6dec3bd9d7d904c0949f7/matplotlib-3.10.5-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c49465bf689c4d59d174d0c7795fb42a21d4244d11d70e52b8011987367ac61", size = 8672192, upload-time = "2025-07-31T18:09:31.407Z" }, ] +[[package]] +name = "mdformat" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" }, +] + +[[package]] +name = "mdformat-gfm" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdit-py-plugins" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", hash = "sha256:d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623", size = 10112, upload-time = "2025-10-16T09:12:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", hash = "sha256:7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005", size = 10970, upload-time = "2025-10-16T09:12:21.276Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -654,6 +714,8 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "matplotlib" }, + { name = "mdformat" }, + { name = "mdformat-gfm" }, { name = "numpy" }, { name = "pandas" }, { name = "pre-commit" }, @@ -671,6 +733,8 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.109.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.10.3" }, + { name = "mdformat", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "mdformat-gfm", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "numpy", marker = "extra == 'dev'", specifier = ">=2.3.1" }, { name = "pandas", marker = "extra == 'dev'", specifier = ">=2.3.1" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.2.0" }, @@ -1214,3 +1278,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca373 wheels = [ { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +]