diff --git a/Backend/API_DOCS.md b/Backend/API_DOCS.md index e910e02..801b1d4 100644 --- a/Backend/API_DOCS.md +++ b/Backend/API_DOCS.md @@ -1,146 +1,884 @@ -# Authentication API Documentation +# TOC-Simulator API Documentation -Base URL: `http://localhost:8000` (dev) | `https://toc-simulator-backend.onrender.com` (prod) +**Base URLs:** +- Development: `http://localhost:8000` +- Production: `https://toc-simulator-backend.onrender.com` -## Endpoints +--- +j +## ๐Ÿ” Authentication Endpoints ### 1. Register +Create a new user account. Email verification required before login. + ```http POST /auth/register/ Content-Type: application/json ``` -**Request:** +**Request Body:** ```json { - "username": "string", - "email": "string", - "password": "string", - "re_password": "string", - "first_name": "string", - "last_name": "string" + "username": "johndoe", + "email": "john@example.com", + "password": "SecurePass123!", + "re_password": "SecurePass123!", + "first_name": "John", + "last_name": "Doe" } ``` -**Response (201):** +**Success Response (201):** ```json { - "email": "string", + "email": "john@example.com", "message": "Registration successful. Please check your email to verify your account." } ``` +**Error Response (400):** +```json +{ + "email": ["User with this email already exists."], + "password": ["Password must be at least 8 characters long."] +} +``` + --- ### 2. Verify Email +Verify email with 6-character code sent to user's email. + ```http POST /auth/verify-email/ Content-Type: application/json ``` -**Request:** +**Request Body:** ```json { - "email": "string", - "code": "string" + "email": "john@example.com", + "code": "ABC123" } ``` -**Response (200):** +**Success Response (200):** ```json { "message": "Email verified successfully", - "access": "jwt_token", - "refresh": "jwt_token", + "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": 1, - "email": "string", - "username": "string", - "first_name": "string", - "last_name": "string" + "email": "john@example.com", + "username": "johndoe", + "first_name": "John", + "last_name": "Doe" } } ``` +**Error Response (400):** +```json +{ + "error": "Invalid or expired verification code" +} +``` + --- ### 3. Login +Login with verified email and password to get JWT tokens. + ```http POST /auth/login/ Content-Type: application/json ``` -**Request:** +**Request Body:** +```json +{ + "email": "john@example.com", + "password": "SecurePass123!" +} +``` + +**Success Response (200):** ```json { - "email": "string", - "password": "string" + "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` -**Response (200):** +**Error Response (401):** ```json { - "refresh": "jwt_token", - "access": "jwt_token" + "detail": "No active account found with the given credentials" } ``` -**Note:** User must verify email before login. Unverified accounts will receive `401 Unauthorized`. +**Note:** Email must be verified before login. Unverified users get 401 error. --- ### 4. Refresh Token +Get new access token using refresh token. + ```http POST /auth/token/refresh/ Content-Type: application/json ``` -**Request:** +**Request Body:** +```json +{ + "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Success Response (200):** +```json +{ + "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +--- + +## ๐Ÿค– Simulation Endpoints + +**Authentication Required:** All simulation endpoints require Bearer token except `/shared/` + +**Header:** +```http +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +--- + +### 5. List Sessions +Get all user's simulation sessions with pagination. + +```http +GET /simulations/sessions/ +Authorization: Bearer +``` + +**Query Parameters:** +- `page` (optional): Page number (default: 1) +- `page_size` (optional): Items per page (default: 10, max: 100) +- `type` (optional): Filter by automata type (DFA, NFA, PDA, TM, REGEX) +- `is_favorite` (optional): Filter favorites (true/false) +- `search` (optional): Search in session_name or description +- `ordering` (optional): Sort by field (e.g., `-created_at`, `session_name`) + +**Example Request:** +```http +GET /simulations/sessions/?page=1&page_size=10&type=DFA&ordering=-created_at +Authorization: Bearer +``` + +**Success Response (200):** +```json +{ + "count": 42, + "next": "http://localhost:8000/simulations/sessions/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "public_id": "550e8400-e29b-41d4-a716-446655440000", + "session_name": "Email Validator DFA", + "automata_type": "DFA", + "is_favorite": true, + "is_shared": false, + "created_at": "2024-12-15T10:30:00Z", + "last_accessed_at": "2024-12-15T14:20:00Z", + "run_count": 15 + } + ] +} +``` + +--- + +### 6. Create Session +Create a new simulation session. + +```http +POST /simulations/sessions/ +Content-Type: application/json +Authorization: Bearer +``` + +**Request Body:** +```json +{ + "session_name": "Email Validator DFA", + "description": "DFA that validates email addresses with @ and .", + "automata_type": "DFA", + "automata_data": { + "states": ["q0", "q1", "q2", "q3"], + "alphabet": ["a-z", "0-9", "@", "."], + "transitions": [ + {"from": "q0", "to": "q1", "symbol": "a-z"}, + {"from": "q1", "to": "q1", "symbol": "a-z"}, + {"from": "q1", "to": "q2", "symbol": "@"}, + {"from": "q2", "to": "q3", "symbol": "a-z"}, + {"from": "q3", "to": "q3", "symbol": "."} + ], + "start_state": "q0", + "accept_states": ["q3"] + } +} +``` + +**Success Response (201):** +```json +{ + "id": 1, + "public_id": "550e8400-e29b-41d4-a716-446655440000", + "session_name": "Email Validator DFA", + "description": "DFA that validates email addresses with @ and .", + "automata_type": "DFA", + "automata_data": { ... }, + "is_favorite": false, + "is_shared": false, + "share_url": null, + "is_owner": true, + "created_at": "2024-12-15T10:30:00Z", + "updated_at": "2024-12-15T10:30:00Z", + "last_accessed_at": "2024-12-15T10:30:00Z", + "state_count": 4, + "transition_count": 5, + "runs": [] +} +``` + +--- + +### 7. Get Session Details +Retrieve specific session by UUID. + +```http +GET /simulations/sessions/{public_id}/ +Authorization: Bearer +``` + +**Example:** +```http +GET /simulations/sessions/550e8400-e29b-41d4-a716-446655440000/ +Authorization: Bearer +``` + +**Success Response (200):** +```json +{ + "id": 1, + "public_id": "550e8400-e29b-41d4-a716-446655440000", + "session_name": "Email Validator DFA", + "description": "DFA that validates email addresses", + "automata_type": "DFA", + "automata_data": { + "states": ["q0", "q1", "q2", "q3"], + "transitions": [...], + "alphabet": ["a-z", "0-9", "@", "."], + "start_state": "q0", + "accept_states": ["q3"] + }, + "is_favorite": true, + "is_shared": false, + "share_url": null, + "is_owner": true, + "created_at": "2024-12-15T10:30:00Z", + "updated_at": "2024-12-15T10:30:00Z", + "last_accessed_at": "2024-12-15T14:20:00Z", + "state_count": 4, + "transition_count": 5, + "runs": [ + { + "id": 1, + "input_string": "user@example.com", + "is_accepted": true, + "execution_time": 0.023, + "result_steps": ["q0", "q1", "q1", "q1", "q2", "q3"], + "created_at": "2024-12-15T10:35:00Z" + } + ] +} +``` + +--- + +### 8. Update Session (Full) +Full update of session (all fields required). + +```http +PUT /simulations/sessions/{public_id}/ +Content-Type: application/json +Authorization: Bearer +``` + +**Request Body:** +```json +{ + "session_name": "Updated Email Validator", + "description": "Updated description", + "automata_type": "DFA", + "automata_data": { + "states": ["q0", "q1"], + "transitions": [], + "alphabet": ["a"], + "start_state": "q0", + "accept_states": ["q1"] + }, + "is_favorite": true +} +``` + +--- + +### 9. Update Session (Partial) +Partial update of session (only send fields to change). + +```http +PATCH /simulations/sessions/{public_id}/ +Content-Type: application/json +Authorization: Bearer +``` + +**Request Body (Example - Update name only):** +```json +{ + "session_name": "New Session Name" +} +``` + +**Request Body (Example - Toggle favorite):** +```json +{ + "is_favorite": true +} +``` + +--- + +### 10. Delete Session +Delete a simulation session permanently. + +```http +DELETE /simulations/sessions/{public_id}/ +Authorization: Bearer +``` + +**Success Response (204):** +``` +No Content +``` + +--- + +### 11. Save Simulation Run +Save a test run result for a session. + +```http +POST /simulations/sessions/{public_id}/save_run/ +Content-Type: application/json +Authorization: Bearer +``` + +**Request Body:** +```json +{ + "input_string": "user@example.com", + "is_accepted": true, + "execution_time": 0.023, + "result_steps": ["q0", "q1", "q1", "q1", "q2", "q3", "q3", "q3", "q3"] +} +``` + +**Success Response (201):** +```json +{ + "id": 1, + "input_string": "user@example.com", + "is_accepted": true, + "execution_time": 0.023, + "result_steps": ["q0", "q1", "q1", "q1", "q2", "q3", "q3", "q3", "q3"], + "created_at": "2024-12-15T10:35:00Z" +} +``` + +--- + +### 12. Duplicate Session +Create a copy of existing session. + +```http +POST /simulations/sessions/{public_id}/duplicate/ +Authorization: Bearer +``` + +**Success Response (201):** +```json +{ + "id": 2, + "public_id": "660e8400-e29b-41d4-a716-446655440001", + "session_name": "Email Validator DFA (Copy)", + "description": "DFA that validates email addresses", + "automata_type": "DFA", + "automata_data": { ... }, + "is_favorite": false, + "is_shared": false, + "created_at": "2024-12-15T15:00:00Z", + "runs": [] +} +``` + +--- + +### 13. Toggle Favorite +Toggle favorite status of session. + +```http +POST /simulations/sessions/{public_id}/toggle_favorite/ +Authorization: Bearer +``` + +**Success Response (200):** +```json +{ + "is_favorite": true, + "message": "Session marked as favorite" +} +``` + +--- + +### 14. Generate Share Link +Enable sharing and get shareable URL. Only owner can generate. + +```http +POST /simulations/sessions/{public_id}/generate_share_link/ +Authorization: Bearer +``` + +**Success Response (200):** +```json +{ + "message": "Share link generated successfully", + "share_url": "/shared/550e8400-e29b-41d4-a716-446655440000", + "public_id": "550e8400-e29b-41d4-a716-446655440000", + "is_shared": true, + "full_url": "http://localhost:8000/shared/550e8400-e29b-41d4-a716-446655440000" +} +``` + +--- + +### 15. Revoke Share Link +Disable sharing. Only owner can revoke. + +```http +POST /simulations/sessions/{public_id}/revoke_share_link/ +Authorization: Bearer +``` + +**Success Response (200):** +```json +{ + "message": "Share link revoked successfully", + "is_shared": false +} +``` + +--- + +### 16. View Shared Session (PUBLIC) +View shared session without authentication. + +```http +GET /simulations/sessions/{public_id}/shared/ +``` + +**No Authorization Required!** + +**Success Response (200):** +```json +{ + "id": 1, + "public_id": "550e8400-e29b-41d4-a716-446655440000", + "session_name": "Email Validator DFA", + "description": "DFA that validates email addresses", + "automata_type": "DFA", + "automata_data": { ... }, + "is_favorite": false, + "is_shared": true, + "share_url": "http://localhost:8000/shared/550e8400-e29b-41d4-a716-446655440000", + "is_owner": false, + "created_at": "2024-12-15T10:30:00Z", + "updated_at": "2024-12-15T10:30:00Z", + "state_count": 4, + "transition_count": 5, + "runs": [...], + "shared_by": { + "username": "johndoe", + "first_name": "John", + "last_name": "Doe" + } +} +``` + +**Error Response (404):** +```json +{ + "error": "Session not found or not shared" +} +``` + +--- + +### 17. List Favorites +Get all favorite sessions. + +```http +GET /simulations/sessions/favorites/ +Authorization: Bearer +``` + +**Success Response (200):** +```json +{ + "count": 5, + "next": null, + "previous": null, + "results": [...] +} +``` + +--- + +### 18. List Recent Sessions +Get recently accessed sessions (last 7 days by default). + +```http +GET /simulations/sessions/recent/ +Authorization: Bearer +``` + +**Query Parameters:** +- `days` (optional): Number of days to look back (default: 7) + +**Example:** +```http +GET /simulations/sessions/recent/?days=30 +Authorization: Bearer +``` + +**Success Response (200):** ```json { - "refresh": "jwt_token" + "count": 12, + "next": null, + "previous": null, + "results": [...] } ``` -**Response (200):** +--- + +### 19. Get Statistics +Get user's simulation statistics. + +```http +GET /simulations/sessions/statistics/ +Authorization: Bearer +``` + +**Success Response (200):** ```json { - "access": "jwt_token", - "refresh": "jwt_token" + "total_sessions": 42, + "favorites_count": 8, + "shared_count": 3, + "recent_count": 12 } ``` --- -## Authentication +### 20. List All Runs +Get all simulation runs from user's sessions. + +```http +GET /simulations/runs/ +Authorization: Bearer +``` + +**Query Parameters:** +- `page`, `page_size` (pagination) + +**Success Response (200):** +```json +{ + "count": 150, + "next": "http://localhost:8000/simulations/runs/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "input_string": "user@example.com", + "is_accepted": true, + "execution_time": 0.023, + "result_steps": [...], + "created_at": "2024-12-15T10:35:00Z" + } + ] +} +``` + +--- -For protected endpoints, include the access token in the Authorization header: +### 21. Get Run Details +Get specific run details. ```http -Authorization: Bearer +GET /simulations/runs/{id}/ +Authorization: Bearer +``` + +**Success Response (200):** +```json +{ + "id": 1, + "input_string": "user@example.com", + "is_accepted": true, + "execution_time": 0.023, + "result_steps": ["q0", "q1", "q1", "q1", "q2", "q3"], + "created_at": "2024-12-15T10:35:00Z" +} ``` -## Token Lifecycle +--- + +## ๐Ÿ“‹ General Information + +### Authentication Header +For all protected endpoints (except PUBLIC ones): + +```http +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` -- **Access Token:** 60 minutes -- **Refresh Token:** 7 days +### Token Lifecycle +- **Access Token Expiry:** 60 minutes +- **Refresh Token Expiry:** 7 days - Refresh tokens rotate on use (new refresh token returned) -## Email Verification +### Email Verification +- Verification codes: **6 characters** (alphanumeric, no ambiguous chars like 0, O, 1, l) +- Code expiry: **10 minutes** +- Single-use codes (cannot be reused) +- Email sent immediately after registration + +### Pagination +All list endpoints support pagination: +- Default page size: 10 +- Max page size: 100 +- Query params: `page`, `page_size` + +### Filtering & Search +Sessions can be filtered/searched: +- `type`: Filter by automata type (DFA, NFA, PDA, TM, REGEX) +- `is_favorite`: Filter favorites (true/false) +- `search`: Search in session_name or description +- `ordering`: Sort by field (prefix with `-` for descending) + +### Automata Types +Supported values for `automata_type`: +- `DFA` - Deterministic Finite Automaton +- `NFA` - Nondeterministic Finite Automaton +- `PDA` - Pushdown Automaton +- `TM` - Turing Machine +- `REGEX` - Regular Expression + +### Error Responses + +**Validation Error (400):** +```json +{ + "field_name": ["Error message here"], + "detail": "Additional error details" +} +``` + +**Unauthorized (401):** +```json +{ + "detail": "Authentication credentials were not provided." +} +``` -- Verification codes are **6 characters** (alphanumeric, excluding ambiguous characters) -- Codes expire after **10 minutes** -- Codes are **single-use** and cannot be reused -- Users receive verification email immediately after registration +**Forbidden (403):** +```json +{ + "error": "Only the owner can perform this action" +} +``` -## Error Responses +**Not Found (404):** +```json +{ + "detail": "Not found." +} +``` +**Server Error (500):** ```json { - "field_name": ["error message"], - "detail": "error description" + "detail": "Internal server error" +} +``` + +--- + +## ๐Ÿงช Postman Collection Tips + +### Environment Variables +Set these in Postman environment: +- `base_url`: `http://localhost:8000` or `https://toc-simulator-backend.onrender.com` +- `access_token`: Your JWT access token (auto-updated via scripts) +- `refresh_token`: Your JWT refresh token + +### Pre-request Script (for auto-token refresh) +Add to collection level: +```javascript +// Check if access token is about to expire +const tokenExp = pm.environment.get('token_expiry'); +if (tokenExp && Date.now() > tokenExp - 60000) { + // Refresh token + pm.sendRequest({ + url: pm.environment.get('base_url') + '/auth/token/refresh/', + method: 'POST', + header: {'Content-Type': 'application/json'}, + body: { + mode: 'raw', + raw: JSON.stringify({ + refresh: pm.environment.get('refresh_token') + }) + } + }, (err, res) => { + if (!err) { + const data = res.json(); + pm.environment.set('access_token', data.access); + pm.environment.set('refresh_token', data.refresh); + pm.environment.set('token_expiry', Date.now() + 3600000); + } + }); +} +``` + +### Tests Script (for login endpoint) +Save tokens automatically: +```javascript +if (pm.response.code === 200) { + const data = pm.response.json(); + pm.environment.set('access_token', data.access); + pm.environment.set('refresh_token', data.refresh); + pm.environment.set('token_expiry', Date.now() + 3600000); +} +``` + +--- + +## ๐Ÿš€ Quick Start Guide + +### 1. Register & Verify +```bash +# 1. Register +POST /auth/register/ +Body: { "username": "test", "email": "test@example.com", ... } + +# 2. Check email for code (e.g., "ABC123") + +# 3. Verify +POST /auth/verify-email/ +Body: { "email": "test@example.com", "code": "ABC123" } + +# Save the tokens from response! +``` + +### 2. Create First Session +```bash +POST /simulations/sessions/ +Headers: Authorization: Bearer +Body: { + "session_name": "My First DFA", + "automata_type": "DFA", + "automata_data": { + "states": ["q0", "q1"], + "alphabet": ["a", "b"], + "transitions": [{"from": "q0", "to": "q1", "symbol": "a"}], + "start_state": "q0", + "accept_states": ["q1"] + } +} + +# Note the public_id from response +``` + +### 3. Test Simulation +```bash +POST /simulations/sessions/{public_id}/save_run/ +Headers: Authorization: Bearer +Body: { + "input_string": "aaa", + "is_accepted": true, + "execution_time": 0.01, + "result_steps": ["q0", "q1", "q1", "q1"] } ``` -Common status codes: `400` (validation error), `401` (unauthorized), `404` (not found), `500` (server error) +### 4. Share Session +```bash +POST /simulations/sessions/{public_id}/generate_share_link/ +Headers: Authorization: Bearer + +# Copy the full_url from response and share with anyone! +``` + +--- + +## ๐Ÿ“Š Common Use Cases + +### Use Case 1: Get User's DFA Sessions +```http +GET /simulations/sessions/?type=DFA&ordering=-created_at +Authorization: Bearer +``` + +### Use Case 2: Search Sessions +```http +GET /simulations/sessions/?search=email&page_size=20 +Authorization: Bearer +``` + +### Use Case 3: Get Recent Activity +```http +GET /simulations/sessions/recent/?days=14 +Authorization: Bearer +``` + +### Use Case 4: Get Dashboard Stats +```http +GET /simulations/sessions/statistics/ +Authorization: Bearer +``` + +### Use Case 5: View Someone's Shared Automaton +```http +GET /simulations/sessions/{uuid}/shared/ +# No auth needed! +``` diff --git a/Backend/apps/simulations/__init__.py b/Backend/apps/simulations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Backend/apps/simulations/admin.py b/Backend/apps/simulations/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/Backend/apps/simulations/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/Backend/apps/simulations/apps.py b/Backend/apps/simulations/apps.py new file mode 100644 index 0000000..66d1032 --- /dev/null +++ b/Backend/apps/simulations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SimulationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.simulations' diff --git a/Backend/apps/simulations/migrations/0001_initial.py b/Backend/apps/simulations/migrations/0001_initial.py new file mode 100644 index 0000000..e3ec175 --- /dev/null +++ b/Backend/apps/simulations/migrations/0001_initial.py @@ -0,0 +1,76 @@ +# Generated by Django 5.1.3 on 2025-12-11 10:35 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SimulationSessions', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('session_name', models.CharField(default='Untitled Session', help_text='Name of the simulation session', max_length=255)), + ('description', models.TextField(blank=True, default='', help_text='Description of the simulation session')), + ('automata_type', models.CharField(choices=[('DFA', 'Deterministic Finite Automaton'), ('NFA', 'Nondeterministic Finite Automaton'), ('TM', 'Turing Machine'), ('REGEX', 'Regular Expression')], db_index=True, default='DFA', max_length=10)), + ('automata_data', models.JSONField(help_text='JSON representation of the automata configuration')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('last_accessed_at', models.DateTimeField(auto_now=True, db_index=True)), + ('is_shared', models.BooleanField(default=False)), + ('is_favorite', models.BooleanField(db_index=True, default=False)), + ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Public identifier for sharing sessions', unique=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='simulation_sessions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Simulation Session', + 'verbose_name_plural': 'Simulation Sessions', + 'db_table': 'simulation_sessions', + 'ordering': ['-last_accessed_at'], + 'permissions': [('can_share_session', 'Can share sesstion with others'), ('can_view_others_sessions', 'Can view sessions shared by others')], + }, + ), + migrations.CreateModel( + name='SimulationRun', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('input_string', models.CharField(help_text='The input string tested', max_length=1000)), + ('is_accepted', models.BooleanField(help_text='Whether the automaton accepted this input')), + ('execution_time', models.FloatField(help_text='Execution time in milliseconds')), + ('result_steps', models.JSONField(help_text='Step-by-step simulation trace')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='simulations.simulationsessions')), + ], + options={ + 'verbose_name': 'Simulation Run', + 'verbose_name_plural': 'Simulation Runs', + 'db_table': 'simulation_runs', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='simulationsessions', + index=models.Index(fields=['user', '-created_at'], name='simulation__user_id_c2dac8_idx'), + ), + migrations.AddIndex( + model_name='simulationsessions', + index=models.Index(fields=['is_favorite'], name='simulation__is_favo_296cb0_idx'), + ), + migrations.AddConstraint( + model_name='simulationsessions', + constraint=models.UniqueConstraint(fields=('user', 'session_name'), name='unique_session_name_per_user'), + ), + migrations.AddIndex( + model_name='simulationrun', + index=models.Index(fields=['session', '-created_at'], name='simulation__session_8c8f2c_idx'), + ), + ] diff --git a/Backend/apps/simulations/migrations/__init__.py b/Backend/apps/simulations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Backend/apps/simulations/models.py b/Backend/apps/simulations/models.py new file mode 100644 index 0000000..fd1e1d4 --- /dev/null +++ b/Backend/apps/simulations/models.py @@ -0,0 +1,192 @@ +import logging +import uuid +from django.db import models +from django.contrib.auth import get_user_model +from django.forms import ValidationError +from django.utils import timezone +from datetime import timedelta + +User = get_user_model() + +class SimulationSessions(models.Model): + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='simulation_sessions', + db_index=True + ) + session_name = models.CharField( + max_length=255, + default='Untitled Session', + help_text='Name of the simulation session' + ) + description = models.TextField( + blank=True, + default='', + help_text='Description of the simulation session' + ) + + # Restricted types of automata for simulations + AUTOMATA_TYPES = [ + ('DFA', 'Deterministic Finite Automaton'), + ('NFA', 'Nondeterministic Finite Automaton'), + ('TM', 'Turing Machine'), + ('REGEX', 'Regular Expression'), + ] + automata_type = models.CharField( + max_length=10, + choices=AUTOMATA_TYPES, + default='DFA', + db_index=True, + ) + automata_data = models.JSONField( + help_text='JSON representation of the automata configuration' + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True, db_index=True) + last_accessed_at = models.DateTimeField(auto_now=True, db_index=True) + + # Flags + is_shared = models.BooleanField(default=False) + is_favorite = models.BooleanField(default=False, db_index=True) + + # Secure IDs for sharing + public_id = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + help_text='Public identifier for sharing sessions' + ) + class Meta: + db_table = 'simulation_sessions' + ordering = ['-last_accessed_at'] + verbose_name = 'Simulation Session' + verbose_name_plural = 'Simulation Sessions' + indexes = [ + models.Index(fields=['user', '-created_at']), + models.Index(fields=['is_favorite']), + ] + constraints = [ + models.UniqueConstraint( + fields=['user', 'session_name'], + name='unique_session_name_per_user' + ) + ] + permissions = [ + ('can_share_session', 'Can share sesstion with others'), + ('can_view_others_sessions', 'Can view sessions shared by others'), + ] + + def __str__(self): + return f"{self.session_name} ({self.get_automata_type_display()})" + + def __repr__(self): + return f"" + + def clean(self): + super().clean() + if not isinstance(self.automata_data, dict): + raise ValidationError('automata_data must be a valid JSON object') + if 'states' not in self.automata_data: + raise ValidationError('automata_data must contain states information') + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + logger = logging.getLogger(__name__) + logger.info(f"Deleting SimulationSession id={self.id} user={self.user.email}") + super().delete(*args, **kwargs) + + # Business logic methods + + def duplicate(self, new_name=None): + """ + Create a duplicate of the current simulation session. + """ + duplicate_session = SimulationSessions.objects.create( + user=self.user, + session_name=new_name or f"{self.session_name} (Copy)", + description=self.description, + automata_type=self.automata_type, + automata_data=self.automata_data, + is_shared=False, + is_favorite=False + ) + return duplicate_session + @property + def state_count(self): + """ + Return the number of states in the automata. + """ + return len(self.automata_data.get('states', [])) + + @property + def transition_count(self): + """ + Return the number of transitions in the automata. + """ + return len(self.automata_data.get('transitions', [])) + + @property + def is_recent(self): + """ + Check if the session was accessed in the last 7 days. + """ + return self.last_accessed_at >= timezone.now() - timedelta(days=7) + +class SimulationRun(models.Model): + session = models.ForeignKey( + SimulationSessions, + on_delete=models.CASCADE, + related_name='runs' + ) + + input_string = models.CharField( + max_length=1000, + help_text='The input string tested' + ) + + is_accepted = models.BooleanField( + help_text='Whether the automaton accepted this input' + ) + + execution_time = models.FloatField( + help_text='Execution time in milliseconds' + ) + + result_steps = models.JSONField( + help_text='Step-by-step simulation trace' + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'simulation_runs' + ordering = ['-created_at'] + verbose_name = 'Simulation Run' + verbose_name_plural = 'Simulation Runs' + indexes = [ + models.Index(fields=['session', '-created_at']) + ] + + def __str__(self): + status = "โœ“" if self.is_accepted else "โœ—" + return f"{status} '{self.input_string}' on {self.session.session_name}" + +class SimulationSessionManager(models.Manager): + def recent(self, days=7): + """Get sessions created in last N days""" + cutoff = timezone.now() - timedelta(days=days) + return self.filter(created_at__gte=cutoff) + + def by_type(self, automata_type): + """Get sessions of specific type""" + return self.filter(automata_type=automata_type) + + def favorites(self, user): + """Get user's favorite sessions""" + return self.filter(user=user, is_favorite=True) \ No newline at end of file diff --git a/Backend/apps/simulations/serializers.py b/Backend/apps/simulations/serializers.py new file mode 100644 index 0000000..3ab7a18 --- /dev/null +++ b/Backend/apps/simulations/serializers.py @@ -0,0 +1,209 @@ +from rest_framework import serializers +from .models import SimulationSessions, SimulationRun +from django.contrib.auth import get_user_model +import logging + +User = get_user_model() + +class SimulationRunSerializer(serializers.ModelSerializer): + class Meta: + model = SimulationRun + fields = [ + 'id', + 'input_string', + 'is_accepted', + 'execution_time', + 'result_steps', + 'created_at' + ] + read_only_fields = ['id', 'created_at'] + +class SimulationSessionsListSerializer(serializers.ModelSerializer): + run_count = serializers.IntegerField(read_only=True) + class Meta: + model = SimulationSessions + fields = [ + 'id', + 'public_id', + 'session_name', + 'automata_type', + 'is_favorite', + 'is_shared', + 'created_at', + 'last_accessed_at', + 'run_count' + ] + +class SimulationSessionsDetailSerializer(serializers.ModelSerializer): + runs = serializers.SerializerMethodField() + + state_count = serializers.IntegerField(read_only=True) + transition_count = serializers.IntegerField(read_only=True) + + # New fields for sharing + share_url = serializers.SerializerMethodField() + is_owner = serializers.SerializerMethodField() + + class Meta: + model = SimulationSessions + fields = [ + 'id', + 'public_id', + 'session_name', + 'description', + 'automata_type', + 'automata_data', + 'is_favorite', + 'is_shared', + 'share_url', + 'is_owner', + 'created_at', + 'updated_at', + 'last_accessed_at', + 'state_count', + 'transition_count', + 'runs' + ] + + def get_runs(self, obj): + recent_runs = obj.runs.order_by('-created_at')[:5] + return SimulationRunSerializer(recent_runs, many=True).data + + def get_share_url(self, obj): + """ + Returns shareable URL if session is shared. + """ + if obj.is_shared: + request = self.context.get('request') + if request: + return f"{request.scheme}://{request.get_host()}/shared/{obj.public_id}" + return None + + def get_is_owner(self, obj): + """ + Check if current user is the owner. + """ + request = self.context.get('request') + if request and request.user.is_authenticated: + return obj.user == request.user + return False + +class SimulationSessionsCreateSerializer(serializers.ModelSerializer): + class Meta: + model = SimulationSessions + fields = [ + 'public_id', + 'session_name', + 'description', + 'automata_type', + 'automata_data' + ] + read_only_fields = ['public_id'] + + # Validate automata_data structure + def validate_automata_data(self, value): + + required_keys = ['states', 'transitions', 'alphabet'] + + if not isinstance(value, dict): + raise serializers.ValidationError("Automata data must be a JSON object.") + + for key in required_keys: + if key not in value: + raise serializers.ValidationError(f"Automata data must contain '{key}' key.") + + if not isinstance(value['states'], list): + raise serializers.ValidationError("'states' must be a list.") + + if len(value['states']) == 0: + raise serializers.ValidationError("At least one state is required") + + return value + + def validate(self, data): + # Check if user already has session with this name + user = self.context['request'].user + + if SimulationSessions.objects.filter( + user=user, + session_name=data['session_name'] + ).exists(): + raise serializers.ValidationError({ + 'name': 'You already have a session with this name' + }) + return data + + def create(self, validated_data): + logger = logging.getLogger(__name__) + logger.info(f"Creating session: {validated_data['session_name']}") + + return SimulationSessions.objects.create(**validated_data) + +class SimulationSessionsUpdateSerializer(serializers.ModelSerializer): + + session_name = serializers.CharField(required=False) + automata_data = serializers.JSONField(required=False) + + class Meta: + model = SimulationSessions + fields = [ + 'session_name', + 'description', + 'automata_type', + 'automata_data', + 'is_favorite' + ] + + def update(self, instance, validated_data): + + changes = [] + + for field, value in validated_data.items(): + if getattr(instance, field) != value: + changes.append(field) + setattr(instance, field, value) + + instance.save() + + # Log changes + if changes: + logger = logging.getLogger(__name__) + logger.info(f"Updated session {instance.id}: {', '.join(changes)}") + + return instance + +class SimulationSessionsHyperlinkSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = SimulationSessions + fields = ['url', 'session_name', 'automata_type', 'user'] + extra_kwargs = { + 'url': {'view_name': 'session-detail', 'lookup_field': 'public_id'} + } + +class UserBasicSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name'] + +class SimulationSessionWithUserSerializer(serializers.ModelSerializer): + + user = UserBasicSerializer(read_only=True) + is_editable = serializers.SerializerMethodField() + + class Meta: + model = SimulationSessions + fields = [ + 'id', + 'session_name', + 'user', + 'automata_type', + 'is_editable', + 'created_at' + ] + + def get_is_editable(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + + return obj.user == request.user \ No newline at end of file diff --git a/Backend/apps/simulations/tests.py b/Backend/apps/simulations/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/Backend/apps/simulations/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/Backend/apps/simulations/urls.py b/Backend/apps/simulations/urls.py new file mode 100644 index 0000000..c394278 --- /dev/null +++ b/Backend/apps/simulations/urls.py @@ -0,0 +1,21 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import SimulationSessionsViewSet, SimulationRunViewSet + +router = DefaultRouter() + +router.register( + r'sessions', + SimulationSessionsViewSet, + basename='simulation-session' +) + +router.register( + r'runs', + SimulationRunViewSet, + basename='simulation-run' +) + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/Backend/apps/simulations/views.py b/Backend/apps/simulations/views.py new file mode 100644 index 0000000..5682f76 --- /dev/null +++ b/Backend/apps/simulations/views.py @@ -0,0 +1,443 @@ +from rest_framework import viewsets, status, filters +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.pagination import PageNumberPagination +from django_filters.rest_framework import DjangoFilterBackend +from django.shortcuts import get_object_or_404 +from django.db.models import Q, Count, Prefetch +from django.utils import timezone +from datetime import timedelta +import logging +from rest_framework.permissions import BasePermission + +from .models import SimulationSessions, SimulationRun +from .serializers import ( + SimulationSessionsListSerializer, + SimulationSessionsDetailSerializer, + SimulationSessionsCreateSerializer, + SimulationSessionsUpdateSerializer, + SimulationRunSerializer, +) + +logger = logging.getLogger(__name__) + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + max_page_size = 100 + +# Permissions +class IsOwnerOrSharedReadOnly(BasePermission): + """ + Link-based sharing permission: + - Owner: Full CRUD access + - Anyone with UUID link: Read-only (if is_shared=True) + """ + + def has_permission(self, request, view): + # List: Only authenticated users + if view.action == 'list': + return request.user and request.user.is_authenticated + + # Retrieve: Anyone + if view.action == 'retrieve': + return True + + # Create/Update/Delete: Must be authenticated + return request.user and request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + # Owner: Full access + if obj.user == request.user: + return True + + # Shared session: Read-only for anyone with the UUID + if request.method in ['GET', 'HEAD', 'OPTIONS'] and obj.is_shared: + return True + + return False + +# Main ViewSet +class SimulationSessionsViewSet(viewsets.ModelViewSet): + + queryset = SimulationSessions.objects.all() + + serializer_class = SimulationSessionsListSerializer + + permission_classes = [IsOwnerOrSharedReadOnly] + + pagination_class = StandardResultsSetPagination + + lookup_field = 'public_id' + + # Filtering and Searching + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter + ] + filterset_fields = ['automata_type', 'is_favorite', 'is_shared'] + search_fields = ['session_name', 'description'] + ordering_fields = ['created_at', 'updated_at', 'session_name', 'last_accessed_at'] + ordering = ['-last_accessed_at'] + + # Override get_queryset to filter + def get_queryset(self): + """ + Smart queryset for link-based sharing: + - list: Only user's sessions + - retrieve: User's sessions OR shared sessions (secure link-based access) + """ + user = self.request.user + + # Detail view: Allow accessing shared sessions by UUID + if self.action == 'retrieve': + return SimulationSessions.objects.filter( + Q(user=user) | Q(is_shared=True) + ).select_related('user').prefetch_related( + Prefetch( + 'runs', + queryset=SimulationRun.objects.order_by('-created_at') + ) + ) + + # List view: Only user's own sessions + queryset = SimulationSessions.objects.filter( + user=self.request.user + ) + + # Conditional filtering based on query params (must be before select_related) + automata_type = self.request.query_params.get('type') + if automata_type: + queryset = queryset.filter(automata_type=automata_type.upper()) + + # Optimize queries + queryset = queryset.select_related('user') + queryset = queryset.annotate(run_count=Count('runs')) + queryset = queryset.prefetch_related('runs') + + logger.debug(f"Queryset for user {self.request.user.email}: {queryset.query}") + + return queryset + + def get_serializer_class(self): + if self.action == 'list': + return SimulationSessionsListSerializer + + elif self.action == 'create': + return SimulationSessionsCreateSerializer + + elif self.action in ['update', 'partial_update']: + return SimulationSessionsUpdateSerializer + + # Default: Full detail serializer + return SimulationSessionsDetailSerializer + + def perform_create(self, serializer): + session = serializer.save(user=self.request.user) + + logger.info( + f"User {self.request.user.email} created session " + f"'{session.session_name}' (ID: {session.id})" + ) + + def perform_update(self, serializer): + serializer.save(last_accessed_at=timezone.now()) + + logger.info( + f"User {self.request.user.email} updated session " + f"{serializer.instance.id}" + ) + + def perform_destroy(self, instance): + session_name = instance.session_name + session_id = instance.id + + instance.delete() + + logger.warning( + f"User {self.request.user.email} deleted session " + f"'{session_name}' (ID: {session_id})" + ) + + # Custom Actions + @action(detail=True, methods=['post']) + def save_run(self, request, public_id=None): + """ + Custom endpoint: POST /sessions/{id}/save_run/ + + Save a simulation run for the specified session + """ + session = self.get_object() + + serializer = SimulationRunSerializer(data=request.data) + + if not serializer.is_valid(): + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer.save(session=session) + + # Update session's last_accessed timestamp + session.last_accessed_at = timezone.now() + session.save(update_fields=['last_accessed_at']) + + logger.info( + f"Saved run for session {session.id}: " + f"input='{serializer.data['input_string']}' " + f"accepted={serializer.data['is_accepted']}" + ) + + return Response( + { + 'message': 'Run saved successfully', + 'run': serializer.data + }, + status=status.HTTP_201_CREATED + ) + + @action(detail=True, methods=['post']) + def duplicate(self, request, public_id=None): + """ + Custom endpoint: POST /sessions/{id}/duplicate/ + + Duplicate an existing session + """ + session = self.get_object() + + # Get new name from request or generate + new_name = request.data.get('name', f"{session.session_name} (Copy)") + + # Use model method for business logic + duplicate_session = session.duplicate(new_name=new_name) + + # Serialize and return + serializer = self.get_serializer(duplicate_session) + + return Response( + { + 'message': f'Session duplicated successfully', + 'session': serializer.data + }, + status=status.HTTP_201_CREATED + ) + + @action(detail=True, methods=['post']) + def toggle_favorite(self, request, public_id=None): + """ + Custom endpoint: POST /sessions/{id}/toggle_favorite/ + + Toggle favorite status + """ + session = self.get_object() + session.is_favorite = not session.is_favorite + session.save(update_fields=['is_favorite']) + + return Response({ + 'message': f"Session {'added to' if session.is_favorite else 'removed from'} favorites", + 'is_favorite': session.is_favorite + }) + + @action(detail=False, methods=['get']) + def favorites(self, request): + """ + Custom endpoint: GET /sessions/favorites/ + + detail=False: Operates on collection, not single item + No {id} required + """ + queryset = self.filter_queryset( + self.get_queryset().filter(is_favorite=True) + ) + + # Apply pagination + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + # No pagination + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def recent(self, request): + """ + Custom endpoint: GET /sessions/recent/ + + Get recently accessed sessions + """ + days = int(request.query_params.get('days', 7)) + queryset = self.filter_queryset( + self.get_queryset().filter( + last_accessed_at__gte=timezone.now() - timedelta(days=days) + ) + ) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def statistics(self, request): + """ + Custom endpoint: GET /sessions/statistics/ + + Return user's simulation statistics + """ + user_sessions = self.get_queryset() + + stats = { + 'total_sessions': user_sessions.count(), + 'favorites_count': user_sessions.filter(is_favorite=True).count(), + 'shared_count': user_sessions.filter(is_shared=True).count(), + 'recent_count': user_sessions.filter( + created_at__gte=timezone.now() - timedelta(days=7) + ).count(), + } + + return Response(stats) + + # Sharing Actions + @action(detail=True, methods=['post']) + def generate_share_link(self, request, public_id=None): + """ + Custom endpoint: POST /sessions/{uuid}/generate_share_link/ + + Enable sharing and return shareable link. + Only owner can generate links. + + Example response: + { + "message": "Share link generated successfully", + "share_url": "/shared/550e8400-e29b-41d4-a716-446655440000", + "public_id": "550e8400-e29b-41d4-a716-446655440000", + "is_shared": true, + "full_url": "https://api.example.com/shared/550e8400-e29b-41d4-a716-446655440000" + } + """ + session = self.get_object() + + # Only owner can generate share links + if session.user != request.user: + return Response( + {'error': 'Only the owner can share this session'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Enable sharing + session.is_shared = True + session.save(update_fields=['is_shared']) + + # Build shareable URL + share_url = f"/shared/{session.public_id}" + full_url = f"{request.scheme}://{request.get_host()}/shared/{session.public_id}" + + logger.info( + f"User {request.user.email} generated share link for session {session.id}" + ) + + return Response({ + 'message': 'Share link generated successfully', + 'share_url': share_url, + 'public_id': str(session.public_id), + 'is_shared': True, + 'full_url': full_url + }) + + @action(detail=True, methods=['post']) + def revoke_share_link(self, request, public_id=None): + """ + Custom endpoint: POST /sessions/{uuid}/revoke_share_link/ + """ + session = self.get_object() + + # Only owner can revoke + if session.user != request.user: + return Response( + {'error': 'Only the owner can revoke sharing'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Disable sharing + session.is_shared = False + session.save(update_fields=['is_shared']) + + logger.info( + f"User {request.user.email} revoked share link for session {session.id}" + ) + + return Response({ + 'message': 'Share link revoked successfully', + 'is_shared': False + }) + + @action(detail=True, methods=['get'], permission_classes=[]) + def shared(self, request, public_id=None): + """ + Custom endpoint: GET /sessions/{uuid}/shared/ + + PUBLIC endpoint for viewing shared sessions. + NO authentication required! + + Example request: + GET /simulator/sessions/550e8400-e29b-41d4-a716-446655440000/shared/ + + Returns: + - Full session details if shared + - 404 if not found or not shared + - Includes owner info and whether current user is owner + """ + try: + session = SimulationSessions.objects.get( + public_id=public_id, + is_shared=True + ) + except SimulationSessions.DoesNotExist: + return Response( + {'error': 'Session not found or not shared'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Use detail serializer for full data + serializer = SimulationSessionsDetailSerializer( + session, + context={'request': request} + ) + + # Add sharing metadata + data = serializer.data + data['is_owner'] = ( + request.user.is_authenticated and + session.user == request.user + ) + data['shared_by'] = { + 'username': session.user.username, + 'first_name': session.user.first_name, + 'last_name': session.user.last_name + } + + return Response(data) + +class SimulationRunViewSet(viewsets.ReadOnlyModelViewSet): + """ + ReadOnlyModelViewSet: Only list and retrieve + + """ + serializer_class = SimulationRunSerializer + permission_classes = [IsAuthenticated] + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + # Only show runs from user's sessions + return SimulationRun.objects.filter( + session__user=self.request.user + ).select_related('session') + \ No newline at end of file diff --git a/Backend/config/settings.py b/Backend/config/settings.py index d0f9a3b..f141f1f 100644 --- a/Backend/config/settings.py +++ b/Backend/config/settings.py @@ -46,7 +46,8 @@ 'rest_framework', 'rest_framework_simplejwt', 'corsheaders', - 'apps.authentication', + 'apps.authentication', + 'apps.simulations', ] MIDDLEWARE = [ diff --git a/Backend/config/urls.py b/Backend/config/urls.py index a7b5e6d..4c11db9 100644 --- a/Backend/config/urls.py +++ b/Backend/config/urls.py @@ -26,4 +26,5 @@ def health_check(request): path('health/', health_check, name='health_check'), path('admin/', admin.site.urls), path('auth/', include('apps.authentication.urls')), + path('simulations/', include('apps.simulations.urls')), ] diff --git a/Backend/requirements.txt b/Backend/requirements.txt index 9896a3f..0dea7ad 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -1,4 +1,4 @@ -django==5.1.3 +django==6.0 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 django-cors-headers==4.5.0 @@ -7,4 +7,5 @@ psycopg2-binary==2.9.10 python-dotenv==1.0.0 mailjet-rest==1.5.1 gunicorn==21.2.0 -whitenoise==6.6.0 \ No newline at end of file +whitenoise==6.6.0 +django-filter==25.2 \ No newline at end of file