diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..6ce18c785 --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# Fusion-Integrated — copy to .env for local development. +# .env is gitignored. Real environment variables always win. + +DJANGO_SETTINGS_MODULE=config.settings.dev + +# Required in production, no fallback there. +# python -c 'import secrets; print(secrets.token_urlsafe(64))' +# DJANGO_SECRET_KEY= + +DB_NAME=fusion_integrated +DB_USER=fusion_admin +# Set this to whatever your local Postgres uses. +DB_PASSWORD= +DB_HOST=127.0.0.1 +DB_PORT=5432 +# Must stay 0 if PgBouncer is ever put in front in transaction mode. +DB_CONN_MAX_AGE=0 + +# --- Fusion_System_Administrator = the IAM ----------------------------------- +# Identity, RBAC and the common directory (student/faculty info) all live there. +# This service has no user table of its own. +IAM_BASE_URL=http://127.0.0.1:8001 +IAM_API_PREFIX=/api +# Server-to-server credential, for directory reads with no user session behind +# them (a report listing 300 applicants by name). Mint it on the IAM with: +# manage.py service_token --issue fusion-integrated +# Sent as `Authorization: Service fsvc_...` — a different scheme from a user +# session, so the IAM can never mistake this service for a person. +IAM_SERVICE_TOKEN= +IAM_TIMEOUT_SECONDS=5 +IAM_SESSION_CACHE_SECONDS=60 +# Must differ from the console's cookie: on one hostname they overwrite each other. +IAM_AUTH_COOKIE_NAME=fusion_session + +# Optional. Without these, cache falls back to in-process LocMem. +# REDIS_CACHE_URL=redis://127.0.0.1:6379/1 +# REDIS_BROKER_URL=redis://127.0.0.1:6380/0 + +# Documents are Google Drive links, so nothing is written here any more. The +# path only has to stay resolvable for rows attached before that switch. +# Must NOT be inside the repo. Defaults to +# ~/.local/share/fusion-integrated/uploads. +# PLACEMENT_UPLOAD_ROOT= + +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:5173 diff --git a/.gitignore b/.gitignore index a88681399..9f93008f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,78 +1,49 @@ -# Byte-compiled / optimized / DLL files +# --- Secrets ------------------------------------------------------------------ +.env +.env.* +!.env.example +*.pem +*.key +*.p12 + +# --- User data ------------------------------------------------------------------ +# Uploaded documents: real resumes and certificates. Never in version control. +var/ +media/ + +# --- Python --------------------------------------------------------------------- +.venv/ +venv/ __pycache__/ *.py[cod] -*$py.class - -# IDEs # -.vscode -.idea - -# Distribution / packaging -.Python -env/ -build/ -media -develop-eggs/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ *.egg-info/ -.installed.cfg -*.egg - -# Swap files -*.swp - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.import_linter_cache/ htmlcov/ -.tox/ .coverage .coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Flask stuff: -instance/.webassets-cache - -# PyBuilder -target/ +staticfiles/ +*.sqlite3 +*.sql +*.dump -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# dotenv -.env - -# virtualenv: ignore environment folder ending with env. e.g. env, venv, myenv, fusionenv, etc -*env/ - -# Rope project settings -.ropeproject - -# VSCode Settings -#.vscode/settings.json - -# Node Modules +# --- Node / client ---------------------------------------------------------------- node_modules/ - -FusionIIIT/static/ -package-lock.json - - +client/dist/ +client/screenshots/ +*.tsbuildinfo +npm-debug.log* +yarn-error.log + +# --- Generated docs --------------------------------------------------------------- +# Rebuilt by `make module-structure`; shared as a file, not carried in the repo. +docs/module-structure.pdf +docs/module-structure.html + +# --- Editors and OS --------------------------------------------------------------- .DS_Store -**/generated.pdf +.idea/ +.vscode/ +*.swp diff --git a/.importlinter b/.importlinter new file mode 100644 index 000000000..d964dc3c7 --- /dev/null +++ b/.importlinter @@ -0,0 +1,68 @@ +[importlinter] +root_packages = + config + core + fusion_auth + modules +# Required because the domain contract forbids external packages +# (django, rest_framework, celery). +include_external_packages = True + +[importlinter:contract:modules-only-touch-contracts] +name = Modules may reach each other ONLY through contracts.py +type = forbidden +source_modules = + modules.placement + modules.directory + modules.accesscontrol +forbidden_modules = + modules.placement.models + modules.placement.services + modules.placement.selectors + modules.placement.api + modules.placement.domain + modules.directory.models + modules.directory.services + modules.directory.api + modules.accesscontrol.models +# DIRECT imports only. contracts.py legitimately imports its OWN module's +# models and services, so following the chain transitively would make this +# contract impossible to satisfy. What we forbid is a module reaching straight +# into another module's internals. +allow_indirect_imports = true +# contracts.py is deliberately absent from the list above — it is the door. + +[importlinter:contract:domain-is-pure-python] +name = domain/ must not import Django, DRF or Celery +type = forbidden +source_modules = +# Every module's domain, not just the first one written. + modules.placement.domain +forbidden_modules = + django + rest_framework + celery + +# NOTE on `devtools`: it is intentionally NOT listed as a module anywhere in +# this file. Seeding fixtures is cross-cutting by nature — it touches placement +# and directory at once — so it is a tool, not a bounded context. It +# is installed in dev/test settings only. Declaring the exemption here keeps it +# visible instead of it quietly becoming a back door. + +[importlinter:contract:core-is-a-leaf] +name = core/ must not import any module +type = forbidden +source_modules = core +forbidden_modules = + modules + fusion_auth + +[importlinter:contract:placement-layers] +name = Layering inside placement +type = layers +layers = + modules.placement.api + modules.placement.services + modules.placement.selectors + modules.placement.models + modules.placement.domain diff --git a/01_ELM_Problem_Statement_v1.4_FINAL.docx b/01_ELM_Problem_Statement_v1.4_FINAL.docx new file mode 100644 index 000000000..4fbac1e26 Binary files /dev/null and b/01_ELM_Problem_Statement_v1.4_FINAL.docx differ diff --git a/02_ELM_Functional_Requirement_Specification_v1.5_FINAL.xlsx b/02_ELM_Functional_Requirement_Specification_v1.5_FINAL.xlsx new file mode 100644 index 000000000..cdf1174fd Binary files /dev/null and b/02_ELM_Functional_Requirement_Specification_v1.5_FINAL.xlsx differ diff --git a/03_ELM_Use_Case_Specifications_v1.4_FINAL.docx b/03_ELM_Use_Case_Specifications_v1.4_FINAL.docx new file mode 100644 index 000000000..49678c359 Binary files /dev/null and b/03_ELM_Use_Case_Specifications_v1.4_FINAL.docx differ diff --git a/04_ELM_Business_Rule_Specifications_v1.4_FINAL.docx b/04_ELM_Business_Rule_Specifications_v1.4_FINAL.docx new file mode 100644 index 000000000..b6fe63324 Binary files /dev/null and b/04_ELM_Business_Rule_Specifications_v1.4_FINAL.docx differ diff --git a/06_ELM_Traceability_Matrix_v1.5_FINAL.xlsx b/06_ELM_Traceability_Matrix_v1.5_FINAL.xlsx new file mode 100644 index 000000000..62c46d0d4 Binary files /dev/null and b/06_ELM_Traceability_Matrix_v1.5_FINAL.xlsx differ diff --git a/06_ELM_Workflow_Specifications_v1.6_FINAL.docx b/06_ELM_Workflow_Specifications_v1.6_FINAL.docx new file mode 100644 index 000000000..a611202e0 Binary files /dev/null and b/06_ELM_Workflow_Specifications_v1.6_FINAL.docx differ diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..6362f7070 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +.DEFAULT_GOAL := help +PY := .venv/bin/python +export DJANGO_SETTINGS_MODULE ?= config.settings.dev + +help: ## show this help + @grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) | awk -F':.*?## ' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +install: ## create the venv and install everything + uv venv --python 3.12 .venv + uv pip install --python $(PY) -e ".[dev]" + +up: ## start postgres + redis + docker compose up -d + +migrate: ## apply migrations + $(PY) manage.py migrate + +seed: ## register modules and load sample data + $(PY) manage.py seed_modules + $(PY) manage.py seed_demo + +dev: ## run the server + $(PY) manage.py runserver 0.0.0.0:8002 + +test: ## run the test suite + $(PY) -m pytest + +check: ## everything CI runs + $(PY) manage.py check + $(PY) manage.py makemigrations --check --dry-run + .venv/bin/ruff check . + .venv/bin/lint-imports + $(PY) ops/checks/no_cross_module_fk.py + $(PY) ops/checks/contracts_are_plural.py + $(PY) ops/checks/nav_matches_routes.py + $(PY) manage.py permission_manifest --check + $(MAKE) schema-check + $(PY) -m pytest -q + +check-client: ## the client half of CI + cd client && npm run typecheck && npm test && npm run build + +SCHEMA := openapi/fusion-integrated.v1.yaml + +schema: ## regenerate the committed OpenAPI schema + $(PY) manage.py spectacular --fail-on-warn --file $(SCHEMA) + +permissions: ## regenerate the permission manifest the IAM seeds from + $(PY) manage.py permission_manifest + +module-structure: ## regenerate the module-structure reference PDF + $(PY) ops/docs/module_structure.py + +schema-check: ## the committed schema must match the code + @$(PY) manage.py spectacular --fail-on-warn --file /tmp/openapi.check.yaml + @diff -u $(SCHEMA) /tmp/openapi.check.yaml > /dev/null \ + || { echo "$(SCHEMA) is stale — run 'make schema'"; exit 1; } + @echo "openapi schema matches the code" + +.PHONY: help install up migrate seed dev test check check-client schema \ + schema-check permissions module-structure diff --git a/README.md b/README.md index 802ecbd2d..be080b046 100644 --- a/README.md +++ b/README.md @@ -1,272 +1,203 @@ -# FusionIIIT (Backend) +# Fusion-Integrated -**FusionIIIT** is the automation of various functionalities, modules and tasks of/for **PDPM Indian Institute of Information Technology, Design and Manufacturing, Jabalpur** being developed in `python3.8` and using `Django` Webframework. +The non-academic platform. **Placement Cell** first; more modules later. +**Independent modules, one login.** -## Critical Prerequisites & Software Requirements +This service holds **no user table**. Identity, RBAC and common directory data +(student info, faculty info, staff info) all come from +**Fusion_System_Administrator**, which is the IAM. -**You MUST strictly use the following versions:** - -* Python `3.8.10` -* pip `21.1.1` -* PostgreSQL `14` -* Git - -## System Configuration - -* Ubuntu `20.04` **(Recommended)** -* *OR* WSL for Windows `10` \(Follow the guide below\) :[Windows Subsystem for Linux Installation Guide for Windows 10](https://docs.microsoft.com/en-us/windows/wsl/install-win10) -* *OR* Windows `7/8/8.1/10` - -## Module-Wise Sync Targets - -For production synchronization targets, refer to: [Fusion-README](https://github.com/FusionIIIT/Fusion-README) - -## Contributing Guidelines - -For contributing to this repository, you have to follow the guidelines given in [CONTRIBUTING.md](./CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for smooth workflow of contributions and changes inside repository. +``` + Fusion_System_Administrator ( = the IAM ) + owns fusion_system_db + · identity — authenticates ALL users + · RBAC — roles, permissions, module grants + · directory — student / faculty / staff info + · its OWN console at /sysadmin/, its own operator login + │ + │ HTTP /api/iam/v1/* + ▼ + Fusion-Integrated ← this repo + owns fusion_integrated + backend → placement (more modules later) + client → the shell: one login, one sidebar + holds only user_id references — no user table +``` -## Full-Stack Module-Wise Git Workflow Guide +## Two logins, on purpose -This section outlines the repository setup, branch management, and contribution workflow for teams working on the Fusion ERP Backend. +| | Who | Where | Against | +|---|---|---|---| +| **User login** | students, faculty, staff | this app's shell | the ERP user pool, via IAM | +| **Operator login** | sysadmin operators | `/sysadmin/`, its own URL | the console's own account pool | -### Phase 1: Team Lead Setup +Users get **one** login for every module they are granted. Operators keep a +separate door to the admin console — different audience, different account set. +Both are backed by the same identity service; they are not the same login page. -1. **Fork the Main Repository:** +--- - * Go to [https://github.com/FusionIIIT/Fusion](https://github.com/FusionIIIT/Fusion) and click **Fork, Uncheck** the **Checkbox,** and click **Create fork.** -2. **Share:** +## Quick start - * Distribute your forked repository URL to your team members +```bash +make install # uv venv + dependencies +make up # postgres + 2× redis (cache and broker, separately) +cp .env.example .env +make migrate seed # schema, module registry, demo data +make dev # http://127.0.0.1:8002 +make check # everything CI runs +``` -### Phase 2: Team Member Setup +Requires Python 3.12, [uv](https://docs.astral.sh/uv/) and Docker. -1. **Fork the Team Lead's Repository:** +--- - * Navigate to your Team Lead's fork and fork it to your own GitHub account -2. **Clone Locally:** +## What "independent module" actually means here - ```sh - git clone https://github.com//Fusion.git - ``` -3. **Set Upstream:** +Not a naming convention — four rules, each enforced by a CI job that has been +tested against a deliberate violation: - ```sh - cd Fusion - git remote add upstream https://github.com//Fusion.git - ``` +| Rule | Enforced by | +|---|---| +| A module may reach another **only** through its `contracts.py` | `lint-imports` | +| **No foreign key crosses a module boundary** | `ops/checks/no_cross_module_fk.py` | +| `domain/` may not import Django, DRF or Celery | `lint-imports` | +| Cross-module getters are **plural** — they take a sequence of ids | `ops/checks/contracts_are_plural.py` | -### Phase 3: Module-Wise Branch Switching +The payoff is concrete: every module owns its own tables and its own migration +file, so lifting one out into its own service later means changing one file's +implementation, not doing schema surgery. -**Note:** v1 (MANUAL : Work on Existing Codebase) and v2 (AI : Work from Scracth according to documnets and Fusion README) - If you are assigned to the AI group, replace `v1` with `v2` in the commands below. +The plural rule is the sneaky-useful one. There is no `get_employment(user_id)` +anywhere, only `get_employments(user_ids)` — which makes the N+1 not merely +discouraged but *unwritable*. -Fetch upstream data first: +### Anatomy of a module -```sh -cd Fusion -git fetch upstream ``` - -Then run your specific module command: - -* **Examination:** `git checkout -b examination-v1 upstream/examination-v1` -* **LMS:** `git checkout -b lms-v1 upstream/lms-v1` -* **Award & Scholarship:** `git checkout -b scholarships-v1 upstream/scholarships-v1` -* **Department:** `git checkout -b department-v1 upstream/department-v1` -* **Other Academic Procedure:** `git checkout -b academic-procedures-v1 upstream/academic-procedures-v1` -* **Announcements:** `git checkout -b announcements-v1 upstream/announcements-v1` -* **Placement Cell + PBI:** `git checkout -b placement-pbi-v1 upstream/placement-pbi-v1` -* **Gymkhana:** `git checkout -b gymkhana-v1 upstream/gymkhana-v1` -* **Primary Health Center:** `git checkout -b health-center-v1 upstream/health-center-v1` -* **Hostel Management:** `git checkout -b hostel-management-v1 upstream/hostel-management-v1` -* **Mess Management:** `git checkout -b mess-management-v1 upstream/mess-management-v1` -* **Visitor Hostel:** `git checkout -b visitor-hostel-v1 upstream/visitor-hostel-v1` -* **Visitor Management System:** `git checkout -b visitor-management-v1 upstream/visitor-management-v1` -* **Dashboards:** `git checkout -b dashboards-v1 upstream/dashboards-v1` -* **File Tracking System:** `git checkout -b file-tracking-v1 upstream/file-tracking-v1` -* **RSPC:** `git checkout -b rspc-v1 upstream/rspc-v1` -* **P&S Management:** `git checkout -b ps-management-v1 upstream/ps-management-v1` -* **HR (EIS):** `git checkout -b hr-eis-v1 upstream/hr-eis-v1` -* **Patent Management System:** `git checkout -b patent-management-v1 upstream/patent-management-v1` -* **Institute Works Department:** `git checkout -b institute-works-v1 upstream/institute-works-v1` -* **Internal Audit and Accounts:** `git checkout -b audit-accounts-v1 upstream/audit-accounts-v1` -* **Complaint Management:** `git checkout -b complaint-management-v1 upstream/complaint-management-v1` - -## How to get started - -* on **Ubuntu**: - - ```sh - // Install the required packages using the following command: - - sudo apt install python3-pip python3-dev python3-venv libpq-dev build-essential git - sudo -H pip3 install --upgrade pip - ``` -* on **Windows**: - - * Get Python 3.8.10 from [here](https://www.python.org/downloads/release/python-3810/) - * Git from [here](https://git-scm.com/download/win) - * Install both using the downloaded `exe` files - **Important:** Make sure to check the box that says **Add Python 3.x to PATH** to ensure that the interpreter will be placed in your execution path - -### Setting up environment - -* Create a virtual environment, run below command inside Fusion Directory or root. - * on **Ubuntu**: `python3 -m venv env` - * on **Windows PowerShell**: `python -m venv env OR py -3.8 -m venv env` -* Activate the *env* - * on **Ubuntu**: `source env/bin/activate` - * on **Windows PowerShell**: `.\env\Scripts\Activate.ps1` - **Note** : On Windows, it may be required to enable the Activate.ps1 script by setting the execution policy for the user. You can do this by issuing the following command: `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` - -### Installing Packages - -Navigate to the Fusion directory and install requirements: - -```sh -cd Fusion -pip install -r requirements.txt +modules/placement/ +├── apps.py Django app config +├── registry.py its sidebar entry — seeded by `manage.py seed_modules` +├── contracts.py THE ONLY thing other modules may import +├── models.py its own tables, its own migrations +├── domain/ pure Python. rules, state machine. no Django. +├── selectors/ all reads. ownership filtering lives here +├── services/ all writes. transactions, audit rows, domain→HTTP translation +├── api/ thin: serializers, views, urls +└── tests/ ``` -### Running server - -* Change directory to **FusionIIIT** `cd FusionIIIT` -* Run the server `python manage.py runserver` +Adding a module: create the directory, add one line to `DOMAIN_MODULES` in +`config/settings/base.py`, write `registry.py`, run `make seed`. Nothing central +to edit — no menu array, no boolean column, no shadow model. -## Phase 4: Syncing, Committing, and PRs +--- -### Production Sync Targets +## How a request is authorized -* **Backend Production Branch:** `prod/acad-react` +Two gates, both server-side, on **every** request: -### Workflow +```python +permission_classes = [ + HasModuleGrant("placement_cell"), # is this module yours at all? + HasPermission("placement_cell.application.view"), # may you do this? +] +``` -1. **Sync with Production:** +Both must pass; the default is deny. `fusion_auth` resolves the caller's session +against IAM (cookie or `Authorization: Token`), builds a `Principal`, and every +check reads that. - * Frequently pull the latest changes from your specific module's production branch to avoid merge conflicts later - * Team lead syncs first, then team members sync from the team lead's fork +**The sidebar is server-driven.** `GET /api/v1/me` returns `navigation` already +filtered and already in render shape — the client does zero filtering, so it +cannot draw a module the server did not send. This replaces the legacy pattern +of a hardcoded client array whose ids had to exactly match database *column* +names. - ```sh - git pull upstream - ``` -2. **Make Changes:** +Ownership is enforced by filtering the queryset in the selector, never by +fetching-then-checking, which is why a foreign id naturally yields **404** and +not 403 (403 would leak existence and turn any id column into an enumeration +oracle). - * Make your code changes and commit them locally to your active module branch -3. **Migrating Database Changes:** +--- - * Make migrations: `python manage.py makemigrations` - * Migrate the changes to the database: `python manage.py migrate` -4. **Committing:** +## Layout - ```sh - git add . - git commit -m "Your descriptive commit message" - ``` -5. **Pushing:** +``` +config/ settings (base/dev/test/prod), urls, celery +core/ shared kernel — error envelope, pagination, request-id, mixins +fusion_auth/ the IAM client, Principal, authentication, the two permission gates +modules/ + directory/ local projection of IAM's user data (a cache with a table) + accesscontrol/ module registry + the server-driven sidebar + placement/ companies, postings, applications, eligibility, state machine +ops/checks/ the boundary enforcement scripts +client/ the shell — Vite + React 18 + TS + Mantine 7.13 +``` - ```sh - git push origin - ``` -6. **Create Pull Request:** +`core/` may not import `modules` — it is a leaf, and CI checks that too. - * Go to `https://github.com//Fusion/tree/` and create a Pull Request - * **Important:** Target the Team Lead's fork, NOT the main FusionIIIT repository +--- -## Working with Code \(Alternative\) +## Testing -* **(Recommended)** Use [Visual Studio Code](https://code.visualstudio.com/) as a text editor. Go through the [Tutorial](https://code.visualstudio.com/docs/python/python-tutorial) for getting started with **Visual Studio Code for Python**.**Note** : Use the following guide if using **WSL** for Development([https://code.visualstudio.com/docs/remote/wsl](https://code.visualstudio.com/docs/remote/wsl)) -* Use the inbuilt **Source Control** feature for checking out, committing, pushing, pulling changes. You can also use [Github Desktop](https://desktop.github.com/) **_\(Windows/Mac only\)_**. -* Refer to below link for best practices regarding commit messages : - ([https://gist.github.com/robertpainsi/b632364184e70900af4ab688decf6f53](https://gist.github.com/robertpainsi/b632364184e70900af4ab688decf6f53)) +```bash +make test # 40 tests, ~0.6s +``` -## Testing Procedure: +Domain tests are pure Python with no database, which is why they run in +milliseconds — that is the whole point of the no-Django-in-`domain/` rule. The +IAM is never contacted: `conftest.py` swaps in an in-memory fake, so the suite +needs no network and no running Fusion_System_Administrator. -### Selenium-webdriver +--- -Selenium is a browser automation library. Most often used for testing -web-applications, Selenium may be used for any task that requires automating -interaction with the browser. +## The client -### Installation +```bash +cd client && npm install && npm run dev # :5173, proxies /api to :8002 +``` -You can visit Selenium Official website and can download the language-specific client drivers(Java in our case) +Vite + React 18 + TypeScript (strict) + Mantine **7.13.4, pinned to match the +sysadmin client** so the two apps look like one product. `theme.ts` and +`AppShellLayout.module.css` are ports of that client's files, not a redesign. -[https://selenium-release.storage.googleapis.com/3.141/selenium-java-3.141.59.zip](https://selenium-release.storage.googleapis.com/3.141/selenium-java-3.141.59.zip) +Routes are built **only** for modules the server granted, so an ungranted module +has no route — deep-linking it hits NotFound rather than an empty shell. Module +pages are lazy chunks (`PostingsPage`, `ApplicationsPage` build separately). -You will need to download additional components to work with each of the major -browsers. The drivers for Chrome, Firefox, and Microsoft's IE and Edge web -browsers are all standalone executables that should be placed on your system -[PATH]. Apple's safaridriver is shipped with Safari 10 for OS X El Capitan and -macOS Sierra. You will need to enable Remote Automation in the Develop menu of -Safari 10 before testing. +## Extracting a module into its own repo -| Browser | Component | -| ----------------- | -------------------------------------------------------------------------------------- | -| Chrome | [ChromeDriver](https://chromedriver.storage.googleapis.com/index.html?path=83.0.4103.39/) | -| Internet Explorer | [IEDriverServer](http://selenium-release.storage.googleapis.com/index.html?path=2.39/) | -| Firefox | [GeckoDriver](https://chromedriver.storage.googleapis.com/index.html?path=83.0.4103.39/) | +This is what "independent" is for. Removing `hr` and `leave` from this codebase +touched **five files**, none of them in `placement`: -### Add the Cucumber Eclipse Plugin for BDD testing +``` +config/settings/base.py one line in DOMAIN_MODULES +config/urls.py one include() +devtools/…/seed_demo.py fixtures +.importlinter contract membership +modules// the directory itself +``` -* Install the Cucumber Eclipse Plugin from Eclipse MarketPlace under help +Nothing in `placement`, `directory`, `accesscontrol`, `core` or `fusion_auth` +referenced them, because the boundary rules make that impossible. The same holds +in reverse: lifting a module into a separate repo means taking its directory and +its migrations, then replacing its `contracts.py` callers with HTTP. -### Getting Started +## Status -* Open the Test folder in Eclipse IDE(You are free to use any IDE) +Working today: the module system, both authorization gates, the server-driven +sidebar, the directory projection, the placement application state machine with +an append-only audit trail, and the client shell with login. - * Open the pom.xml and build the project - * Change the driver path in System.setProperty in line 16 of Step_defination.java -* Under the src/main/resources we have main.feature file to define Scenarios and Steps -* Give the step defination of the defined scenarios and steps in Step_Defination.java under src/main/java +**Blocking next step — the IAM side.** `Fusion_System_Administrator` currently +authenticates operators only; its own router says so: *"this is what makes admin +operators a separate account set from the 3277 managed ERP users."* It needs +ERP-user authentication plus `/api/iam/v1/{auth/login, auth/logout, me, +directory/users}`. The client for those is written and tested against a fake — +the expected contract is in `fusion_auth/client.py`. -## Different modules included +Then: offers and selection rounds, the declared-CPI integration. -* Academic database management -* Academic workflows -* Finance and Accounting -* Placement Cell -* Mess management -* Gymkhana Activities -* Scholarship and Awards Portal -* Employee Management -* Course Management -* Complaint System -* File Tracking System -* Health Centre Mangement -* Visitor's Hostel Management -* Leave Module - -## Notifications Support - -The project now supports notifications across all modules. To implement notifications in your module refer to the instructions below. - -* Create your notification class in [**`./FusionIIIT/notifications/views.py`**](https://github.com/FusionIIIT/Fusion/blob/master/FusionIIIT/notification/views.py) - ``` - def module_notif(sender, recipient, type): - url='slug:slug' - module='ModuleName' - sender = sender - recipient = recipient - verb = '' - - // Type conditioned verb - if type == 'A': - verb = "A Verb" - elif type == 'B': - verb = "B Verb " - notify.send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) - ``` -* Import your Notification class in your module into **`your_module/views.py`** - ``` - from notification.views import module_notif - ``` -* To create a Notification, simply call the class and pass user objects for sender, receiver and a string type. - ``` - module_notif(sender, receiver, type) - ``` -* The Notifications should then appear in the dashboard for the recipient - -## Setting up Fusion using Docker - -- Make sure you have docker & docker-compose setup properly. -- Run `docker-compose up` -- Once the server starts, run `sudo docker exec -i fusion_db_1 psql -U fusion_admin -d fusionlab < path_to_db_dump` +Design documents — architecture, ADRs, the RBAC model, the placement domain and +the academic-snapshot contract — are in [`docs/`](docs/). diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..265625ce0 --- /dev/null +++ b/conftest.py @@ -0,0 +1,148 @@ +"""Shared fixtures. `stub_iam` replaces the IAM client with an in-memory fake, +so the suite runs with no network and no Fusion_System_Administrator.""" +import sys + +import pytest + +from fusion_auth import client as iam_client +from fusion_auth.client import IamSession, UserRef +from fusion_auth.principal import Principal + + +class FakeIam: + """Mirrors IamClient exactly. If the real client grows a method, add it + here too — a fake that has drifted is worse than no fake.""" + + def __init__(self, session=None, users=None, standings=None): + self.session, self.users = session, users or {} + #: Override to make the identity service disagree with the projection. + self.employees = None + self.standings = standings or {} + self.calls = [] + self.login_result = "tok-123" # override per test + self.login_error = None + self.standings_error = None + self.directory_error = None + self.directory = {"count": 0, "limit": 50, "offset": 0, "results": []} + self.filters = {"disciplines": [], "batch_years": [], "programmes": []} + + def login(self, username, password): + self.calls.append(("login", username)) + if self.login_error: + raise self.login_error + return self.login_result + + def logout(self, auth_token): + self.calls.append(("logout", auth_token)) + + def resolve_session(self, *, auth_token=None, cookies=None): + self.calls.append(("resolve_session", auth_token)) + return self.session + + def get_users(self, user_ids): + ids = list(user_ids) + self.calls.append(("get_users", tuple(sorted(ids)))) + return {i: self.users[i] for i in ids if i in self.users} + + def search_users(self, q="", kind=None, limit=25): + return list(self.users.values())[:limit] + + def employee_page(self, *, limit=500, offset=0): + rows = self._employees() + return {"count": len(rows), "limit": limit, "offset": offset, + "results": rows[offset:offset + limit]} + + def count_employees(self): + return len(self._employees()) + + def iter_employees(self, *, page_size=500): + yield from self._employees() + + def _employees(self): + """Whoever the test put in `users` who is not a student.""" + if self.employees is not None: + return list(self.employees) + from modules.directory.models import UserRef + return list(UserRef.objects.filter( + kind__in=("faculty", "staff"), is_active=True)) + + def academic_directory(self, **filters): + self.calls.append(("academic_directory", tuple(sorted(filters.items())))) + if self.directory_error: + raise self.directory_error + return self.directory + + def academic_filters(self): + self.calls.append(("academic_filters", ())) + if self.directory_error: + raise self.directory_error + return self.filters + + def get_academic_standings(self, user_ids): + """A student absent from `standings` has no declared result, which is + what the eligibility engine must fail closed on.""" + ids = list(user_ids) + self.calls.append(("get_academic_standings", tuple(sorted(ids)))) + if self.standings_error: + raise self.standings_error + return {i: self.standings[i] for i in ids if i in self.standings} + + +def make_session(user_id=1001, permissions=(), modules=(), kind="student", + roles=("student",), active_role="student"): + return IamSession( + user_id=user_id, username=f"u{user_id}", display_name=f"User {user_id}", + kind=kind, active_role=active_role, roles=tuple(roles), + permissions=frozenset(permissions), modules=tuple(modules), + ) + + +def make_principal(**kw): + return Principal.from_session(make_session(**kw)) + + +@pytest.fixture(autouse=True) +def _clear_throttle_state(): + """Throttle counters live in the cache and would otherwise carry from one + test into the next — the sixth request of a run failing wherever it landed.""" + from django.core.cache import cache + + cache.clear() + yield + cache.clear() + + +@pytest.fixture +def stub_iam(monkeypatch): + fake = FakeIam() + + def _install(session=None, users=None): + fake.session = session + fake.users = users or {} + # `from ... import get_client` binds the name in every importer, so + # patching the source alone lets the real client leak through. A + # hand-maintained list goes stale silently; sys.modules does not. + patched = 0 + for mod in list(sys.modules.values()): + if mod is None: + continue + name = getattr(mod, "__name__", "") + if not name.startswith(("fusion_auth", "modules", "core", "config")): + continue + if getattr(mod, "get_client", None) is not None: + monkeypatch.setattr(mod, "get_client", lambda: fake) + patched += 1 + monkeypatch.setattr(iam_client, "get_client", lambda: fake) + assert patched, "no get_client bindings were found to patch" + return fake + + _install() + return _install + + +@pytest.fixture +def user_ref(): + def _make(user_id, name="Asha Verma", kind="student", discipline="CSE"): + return UserRef(user_id=user_id, username=f"u{user_id}", display_name=name, + kind=kind, discipline=discipline) + return _make diff --git a/docker-compose.yml b/docker-compose.yml index 62dfc8c39..6c9b61e07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,29 @@ -version: "3.9" services: db: - image: postgres:13-alpine - restart: always - volumes: - - ~/private/var/lib/postgresql:/var/lib/postgresql/data + image: postgres:16-alpine environment: - - PGDATA=/var/lib/postgresql/data/some_name/ - - POSTGRES_DB=fusionlab - - POSTGRES_USER=fusion_admin - - POSTGRES_PASSWORD=hello123 - ports: - - "5432:5432" - app: - build: - context: . - dockerfile: Dockerfile - restart: always - volumes: - - .:/home/app - ports: - - 8000:8000 - command: /bin/bash docker-entrypoint.sh - environment: - - DB_HOST=db - depends_on: - - db \ No newline at end of file + POSTGRES_DB: fusion_integrated + POSTGRES_USER: fusion_admin + POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env} + ports: ["5432:5432"] + volumes: ["fusion_integrated_pg:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fusion_admin -d fusion_integrated"] + interval: 5s + retries: 10 + + # Cache. Eviction here is fine — that is what a cache is for. + redis-cache: + image: redis:7-alpine + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru + ports: ["6379:6379"] + + # Broker. noeviction is NOT optional: an LRU broker silently drops queued + # Celery tasks with no error and no trace. + redis-broker: + image: redis:7-alpine + command: redis-server --maxmemory 256mb --maxmemory-policy noeviction + ports: ["6380:6379"] + +volumes: + fusion_integrated_pg: diff --git a/manage.py b/manage.py new file mode 100644 index 000000000..d24ba7038 --- /dev/null +++ b/manage.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..e5455bb15 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +# This is a deployed application, not a distributable library. Disabling +# package auto-discovery keeps `pip install -e .` working purely as a way to +# resolve dependencies from a single source of truth, without setuptools +# trying to guess which of config/ core/ modules/ is "the package". +[tool.setuptools] +py-modules = [] + +[project] +name = "fusion-integrated" +version = "0.1.0" +description = "Fusion-Integrated — the non-academic platform. Independent modules, one login." +requires-python = ">=3.12" +dependencies = [ + "Django>=5.2,<6.0", + "djangorestframework>=3.15", + "django-cors-headers>=4.4", + "django-filter>=24.3", + "drf-spectacular>=0.27", + "psycopg[binary]>=3.2", + "celery>=5.4", + "redis>=5.0", + "requests>=2.32", + "structlog>=24.4", + "python-dateutil>=2.9", + # Recruiter passwords. These are the only credentials this service stores — + # institute users authenticate at the IAM and we never see their password. + # Recruiters are external parties, so their hashes get the strongest + # available KDF rather than Django's PBKDF2 default. + "argon2-cffi>=23.1", + # The WSGI server ops/systemd/fusion-platform.service runs. Nothing else + # can serve this in production. + "gunicorn>=23.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3", + "pytest-django>=4.9", + "pytest-cov>=5.0", + "factory-boy>=3.3", + "freezegun>=1.5", + "ruff>=0.6", + "import-linter>=2.0", + "responses>=0.25", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" +exclude = ["*/migrations/*", ".venv", "build", "dist"] + +[tool.ruff.lint] +# Wide net; everything switched off below has a stated reason. +select = [ + "F", "E", "W", "I", "N", "UP", "B", "C4", "S", "BLE", "A", "DJ", + "ISC", "PIE", "T20", "RET", "SIM", "TC", "PERF", "RUF", "TRY", + "PLC", "PLE", "PLW", "EXE", "G", "INT", "YTT", "ASYNC", +] +ignore = [ + "S101", # assert is fine in tests + "TRY003", # domain errors carry a sentence written for whoever reads it + "TRY400", # a handled IAM blip is a warning, not an exception + "RUF012", # DRF class attributes are mutable by design + "PLC0415", # function-level imports break real cycles and keep domain/ clean + "ISC001", # conflicts with the formatter + "T201", # ops/checks are CLI scripts; printing is their output + "PLW2901", # rebinding a loop variable is idiomatic in the parsers here + "N818", # *Error is rendered by HTTP; a bare name is a domain signal + "TC001", "TC002", "TC003", # Django imports the app tree at boot anyway +] + +[tool.ruff.lint.per-file-ignores] +# `Decimal as D` keeps a table of money literals readable. +"*/tests/*" = ["S", "E501", "N817"] +"config/settings/*" = ["F403", "F405"] +# A PDF-prose generator; long strings and typographic dashes are its content, not a style defect. +"docs/08-delivery/build_assignment_pdf.py" = ["E501", "RUF001", "ISC004"] + +[tool.pytest.ini_options] +# `--ds` rather than DJANGO_SETTINGS_MODULE: pytest-django lets the +# environment variable win over this file, and a developer exporting +# config.settings.dev would then run a different suite from CI. +DJANGO_SETTINGS_MODULE = "config.settings.test" +python_files = ["test_*.py"] +addopts = "-q --no-header --ds=config.settings.test" +testpaths = ["modules", "core", "fusion_auth"]