Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ on:
jobs:
backend:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/cheatsheet_test
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: cheatsheet_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres -d cheatsheet_test"
--health-interval=10s
--health-timeout=5s
--health-retries=5
defaults:
run:
working-directory: ./backend
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ session-*.md
.env.*
frontend/.env
backend/.env
!backend/.env.example

# Ignore all SQLite DBs, not just the default
*.sqlite3
Expand All @@ -65,4 +66,3 @@ backend/coverage/

# OpenCode/Sisyphus
.sisyphus/

20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele
### Database Features
- **Templates**: Save and manage reusable LaTeX templates
- **Cheat Sheets**: Save and load your cheat sheet projects
- **Practice Problems**: Add practice problems to your sheets
- **Practice Problems**: Add compiler-backed practice-problem blocks with live validation and compiled previews

## Tech Stack

Expand All @@ -35,7 +35,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele
| Frontend | React 18 + Vite |
| Backend | Django 6 + Django REST Framework |
| LaTeX Engine | Tectonic |
| Database | SQLite (dev) / PostgreSQL (Docker/prod) |
| Database | PostgreSQL 15 |
| Container | Docker Compose |

## Project Structure
Expand Down Expand Up @@ -90,6 +90,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele
| GET/PUT/PATCH/DELETE | `/api/cheatsheets/{id}/` | Retrieve/update/delete cheat sheet |
| GET/POST | `/api/problems/` | List/create practice problems |
| GET/PUT/PATCH/DELETE | `/api/problems/{id}/` | Retrieve/update/delete problem |
| POST | `/api/problems/preview/` | Validate and preview a `simple_v1` practice-problem block |

### Available Formula Classes

Expand All @@ -110,19 +111,30 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele

- Python 3.13+
- Node.js 20+
- PostgreSQL 15+ (or Docker)
- Tectonic (for PDF compilation)

### Backend Setup

Start PostgreSQL before running Django locally. The quickest option is:

```bash
docker compose up -d db
```

```bash
cd backend
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
python manage.py migrate
python manage.py runserver
```

`backend/.env.example` is preconfigured for a PostgreSQL instance on `localhost:5432`.
If you want to run Postgres in Docker while keeping the backend local, start it with `docker compose up -d db`.

The API will be available at `http://localhost:8000/api/`.

### Frontend Setup
Expand All @@ -142,19 +154,23 @@ docker compose up --build
```

This builds and starts the Django backend, React frontend, and PostgreSQL database.
The database is also exposed on `localhost:5432` for local backend/test runs.

## Running Tests

### Backend (pytest)

```bash
docker compose up -d db
cd backend
pytest # Run all tests
pytest -v # Run with verbose output
pytest -k "test_name" # Run tests matching pattern
pytest api/tests.py # Run specific test file
```

Tests now run against PostgreSQL using the connection configured in `backend/.env`.

### Frontend (ESLint)

```bash
Expand Down
7 changes: 6 additions & 1 deletion backend/.env.docker
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,backend
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173

# PostgreSQL connection (matches the db service in docker-compose.yml)
DATABASE_URL=postgres://cheatsheet_user:cheatsheet_pass@db:5432/cheatsheet_db
DATABASE_URL=postgresql://cheatsheet_user:cheatsheet_pass@db:5432/cheatsheet_db
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_DB=cheatsheet_db
POSTGRES_USER=cheatsheet_user
POSTGRES_PASSWORD=cheatsheet_pass
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
DJANGO_DEBUG=True
DJANGO_SECRET_KEY=django-insecure-dev-secret-key-change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=cheatsheet_db
POSTGRES_USER=cheatsheet_user
POSTGRES_PASSWORD=cheatsheet_pass
DATABASE_URL=postgresql://cheatsheet_user:cheatsheet_pass@localhost:5432/cheatsheet_db
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ COPY . .

EXPOSE 8000

CMD ["sh", "-c", "until pg_isready -h db -U cheatsheet_user; do sleep 2; done && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]
CMD ["sh", "-c", "export PGPASSWORD=${POSTGRES_PASSWORD:-cheatsheet_pass}; until pg_isready -h ${POSTGRES_HOST:-db} -p ${POSTGRES_PORT:-5432} -U ${POSTGRES_USER:-cheatsheet_user} -d ${POSTGRES_DB:-cheatsheet_db}; do sleep 2; done && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Generated by Django 6.0.4 on 2026-04-20 04:00

from django.db import migrations, models


def backfill_legacy_source_format(apps, schema_editor):
PracticeProblem = apps.get_model('api', 'PracticeProblem')
PracticeProblem.objects.filter(
source_text='',
compiled_latex='',
).exclude(
question_latex='',
answer_latex='',
).update(source_format='latex_legacy')


class Migration(migrations.Migration):

dependencies = [
('api', '0002_cheatsheet_selected_formulas'),
]

operations = [
migrations.AddField(
model_name='practiceproblem',
name='compiled_latex',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='practiceproblem',
name='label',
field=models.CharField(blank=True, default='', max_length=120),
),
migrations.AddField(
model_name='practiceproblem',
name='source_format',
field=models.CharField(default='simple_v1', max_length=20),
),
migrations.AddField(
model_name='practiceproblem',
name='source_text',
field=models.TextField(blank=True, default=''),
),
migrations.AlterField(
model_name='practiceproblem',
name='question_latex',
field=models.TextField(blank=True, default=''),
),
migrations.RunPython(backfill_legacy_source_format, migrations.RunPython.noop),
]
25 changes: 18 additions & 7 deletions backend/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,10 @@ def _build_practice_problems_section(self):

section_lines = ["\\section*{Practice Problems}"]
for problem in problems:
section_lines.append(
f"\\textbf{{Problem {problem.order}:}} {problem.question_latex}"
)
if problem.answer_latex:
section_lines.append(f"\\textbf{{Answer:}} {problem.answer_latex}")
section_lines.append("")
rendered_latex = problem.get_rendered_latex()
if rendered_latex:
section_lines.append(rendered_latex)
section_lines.append("")

return "\n".join(section_lines).rstrip()

Expand Down Expand Up @@ -132,7 +130,11 @@ class PracticeProblem(models.Model):
cheat_sheet = models.ForeignKey(
CheatSheet, on_delete=models.CASCADE, related_name="problems"
)
question_latex = models.TextField()
label = models.CharField(max_length=120, blank=True, default="")
source_format = models.CharField(max_length=20, default="simple_v1")
source_text = models.TextField(blank=True, default="")
compiled_latex = models.TextField(blank=True, default="")
question_latex = models.TextField(blank=True, default="")
answer_latex = models.TextField(blank=True, default="")
order = models.IntegerField(default=0)

Expand All @@ -141,3 +143,12 @@ class Meta:

def __str__(self):
return f"Problem {self.order} - {self.cheat_sheet.title}"

def get_rendered_latex(self):
if self.compiled_latex:
return self.compiled_latex.strip()

lines = [f"\\textbf{{Problem {self.order}:}} {self.question_latex}"]
if self.answer_latex:
lines.append(f"\\textbf{{Answer:}} {self.answer_latex}")
return "\n".join(lines).rstrip()
91 changes: 86 additions & 5 deletions backend/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# DRF serializers for the backend API will be added here.
from rest_framework import serializers

from .models import Template, CheatSheet, PracticeProblem
from .services.practice_problem_compiler import compile_source


class TemplateSerializer(serializers.ModelSerializer):
Expand All @@ -26,11 +27,91 @@ class Meta:
fields = [
"id",
"cheat_sheet",
"label",
"source_format",
"source_text",
"compiled_latex",
"question_latex",
"answer_latex",
"order",
]
read_only_fields = ["id"]
read_only_fields = ["id", "compiled_latex"]
extra_kwargs = {
"question_latex": {"required": False, "allow_blank": True},
"answer_latex": {"required": False, "allow_blank": True},
"source_text": {"required": False, "allow_blank": True},
"label": {"required": False, "allow_blank": True},
"source_format": {"required": False},
}

def validate(self, attrs):
attrs = super().validate(attrs)

instance = getattr(self, "instance", None)
submitted_legacy_fields = any(
field in attrs for field in ["question_latex", "answer_latex"]
)
label = attrs.get("label", getattr(instance, "label", ""))
source_format = attrs.get(
"source_format",
getattr(instance, "source_format", "simple_v1"),
)
source_text = attrs.get("source_text", getattr(instance, "source_text", ""))

if source_format not in {"simple_v1", "latex_legacy"}:
raise serializers.ValidationError(
{"source_format": ["Unsupported source format. Use 'simple_v1' or 'latex_legacy'."]}
)

if submitted_legacy_fields:
raise serializers.ValidationError(
{
"question_latex": [
"Direct LaTeX problem fields are read-only. Use source_text with simple_v1 instead."
]
}
)

if source_text:
if source_format != "simple_v1":
raise serializers.ValidationError(
{"source_format": ["source_text currently supports only 'simple_v1'."]}
)

result = compile_source(source_text, label=label)
if not result.is_valid:
raise serializers.ValidationError(
{
"source_text": [
f"Line {error.line}: {error.message}" for error in result.errors
]
}
)

attrs["source_format"] = "simple_v1"
attrs["compiled_latex"] = result.compiled_latex
return attrs

if source_format == "simple_v1":
raise serializers.ValidationError(
{"source_text": ["Provide source_text for simple_v1 problems."]}
)

return attrs


class PracticeProblemPreviewSerializer(serializers.Serializer):
label = serializers.CharField(required=False, allow_blank=True, default="")
source_format = serializers.CharField(required=False, default="simple_v1")
source_text = serializers.CharField()

def validate(self, attrs):
if attrs.get("source_format") != "simple_v1":
raise serializers.ValidationError(
{"source_format": ["Preview currently supports only 'simple_v1'."]}
)

return attrs


class CheatSheetSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -66,9 +147,9 @@ class CompileRequestSerializer(serializers.Serializer):
content = serializers.CharField(required=False, default="")
cheat_sheet_id = serializers.IntegerField(required=False, default=None)

def validate(self, data):
if not data.get("content") and not data.get("cheat_sheet_id"):
def validate(self, attrs):
if not attrs.get("content") and not attrs.get("cheat_sheet_id"):
raise serializers.ValidationError(
"Provide either 'content' or 'cheat_sheet_id'."
)
return data
return attrs
5 changes: 5 additions & 0 deletions backend/api/services/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .practice_problem_compiler import CompilationResult as CompilationResult
from .practice_problem_compiler import CompilerError as CompilerError
from .practice_problem_compiler import compile_source as compile_source

__all__ = ["CompilationResult", "CompilerError", "compile_source"]
Loading