Spaces:
Configuration error
Configuration error
pushing to hugging face
Browse files- .gitignore +46 -0
- Dockerfile +16 -0
- Procfile +1 -0
- app.py +1489 -0
- check_profiles.py +44 -0
- cleanup_duplicates.py +48 -0
- create_tables.py +57 -0
- debug_profile.py +83 -0
- debug_user_delete.py +59 -0
- migrate_admin_columns.py +62 -0
- models.py +163 -0
- pdf_generator.py +358 -0
- requirements.txt +57 -0
- templates/admin.html +242 -0
- templates/base.html +100 -0
- templates/create_achievements.html +288 -0
- templates/create_education.html +425 -0
- templates/create_introduction.html +185 -0
- templates/create_preview.html +537 -0
- templates/create_profile_summary.html +269 -0
- templates/create_projects.html +433 -0
- templates/create_skills.html +263 -0
- templates/create_work_experience.html +425 -0
- templates/forgot_password.html +55 -0
- templates/profile.html +303 -0
- templates/resumes/resume_modern.html +329 -0
- templates/resumes/resume_standard.html +248 -0
- templates/signin.html +202 -0
- templates/signup.html +235 -0
- test_api.py +41 -0
- test_mock_models.py +178 -0
- test_pdf_generation.py +88 -0
- test_user_data.py +144 -0
- test_weasyprint.py.old +47 -0
- utils.py +47 -0
.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python virtual environments
|
| 2 |
+
.venv/
|
| 3 |
+
venv/
|
| 4 |
+
ENV/
|
| 5 |
+
env/
|
| 6 |
+
|
| 7 |
+
# Python cache / bytecode
|
| 8 |
+
__pycache__/
|
| 9 |
+
*.py[cod]
|
| 10 |
+
*$py.class
|
| 11 |
+
|
| 12 |
+
# Distribution / packaging
|
| 13 |
+
build/
|
| 14 |
+
dist/
|
| 15 |
+
*.egg-info/
|
| 16 |
+
.eggs/
|
| 17 |
+
|
| 18 |
+
# Logs and runtime files
|
| 19 |
+
*.log
|
| 20 |
+
*.out
|
| 21 |
+
*.pid
|
| 22 |
+
|
| 23 |
+
# Unit test / coverage reports
|
| 24 |
+
.coverage
|
| 25 |
+
coverage.xml
|
| 26 |
+
htmlcov/
|
| 27 |
+
.pytest_cache/
|
| 28 |
+
.tox/
|
| 29 |
+
.nox/
|
| 30 |
+
.hypothesis/
|
| 31 |
+
|
| 32 |
+
# Environment variables / secrets
|
| 33 |
+
.env
|
| 34 |
+
.env.*
|
| 35 |
+
*.secret
|
| 36 |
+
|
| 37 |
+
# IDE / editor
|
| 38 |
+
.vscode/
|
| 39 |
+
.idea/
|
| 40 |
+
|
| 41 |
+
# OS files
|
| 42 |
+
.DS_Store
|
| 43 |
+
Thumbs.db
|
| 44 |
+
|
| 45 |
+
claude.md
|
| 46 |
+
CLAUDE.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
|
| 2 |
+
# you will also find guides on how best to write your Dockerfile
|
| 3 |
+
|
| 4 |
+
FROM python:3.12.3
|
| 5 |
+
|
| 6 |
+
RUN useradd -m -u 1000 user
|
| 7 |
+
USER user
|
| 8 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 13 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY --chown=user . /app
|
| 16 |
+
CMD ["gunicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: python app.py
|
app.py
ADDED
|
@@ -0,0 +1,1489 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
# Load environment variables FIRST
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
from flask import Flask, render_template, redirect, url_for, flash, session, request, jsonify, send_file
|
| 9 |
+
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
| 10 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 11 |
+
import requests
|
| 12 |
+
import uuid
|
| 13 |
+
from urllib.parse import urlencode
|
| 14 |
+
import re
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from sqlalchemy.orm import joinedload
|
| 17 |
+
|
| 18 |
+
# Import models and database
|
| 19 |
+
from models import db, User, Introduction, ProfileSummary, WorkExperience, Project, Education, Skill, Achievement, ProfileSectionOrder
|
| 20 |
+
|
| 21 |
+
app = Flask(__name__)
|
| 22 |
+
|
| 23 |
+
# Configure Flask
|
| 24 |
+
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
| 25 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL') or os.environ.get('SQLALCHEMY_DATABASE_URI')
|
| 26 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 27 |
+
|
| 28 |
+
# Initialize extensions
|
| 29 |
+
db.init_app(app)
|
| 30 |
+
# Note: CSRFProtect disabled for now, using simple token validation
|
| 31 |
+
|
| 32 |
+
# Configure Flask-Login
|
| 33 |
+
login_manager = LoginManager()
|
| 34 |
+
login_manager.init_app(app)
|
| 35 |
+
login_manager.login_view = 'signin'
|
| 36 |
+
login_manager.login_message = 'Please sign in to access this page.'
|
| 37 |
+
login_manager.login_message_category = 'info'
|
| 38 |
+
|
| 39 |
+
@login_manager.user_loader
|
| 40 |
+
def load_user(user_id):
|
| 41 |
+
return db.session.get(User, str(user_id))
|
| 42 |
+
|
| 43 |
+
# Admin required decorator
|
| 44 |
+
def admin_required(f):
|
| 45 |
+
"""Decorator to require admin access"""
|
| 46 |
+
@login_required
|
| 47 |
+
def decorated_function(*args, **kwargs):
|
| 48 |
+
if not current_user.is_admin:
|
| 49 |
+
flash('You do not have permission to access this page.', 'error')
|
| 50 |
+
return redirect(url_for('profile'))
|
| 51 |
+
return f(*args, **kwargs)
|
| 52 |
+
decorated_function.__name__ = f.__name__
|
| 53 |
+
return decorated_function
|
| 54 |
+
|
| 55 |
+
# Note: CSRF protection disabled for simplicity in this development version
|
| 56 |
+
|
| 57 |
+
# GitHub OAuth Configuration
|
| 58 |
+
GITHUB_CLIENT_ID = os.environ.get('GITHUB_CLIENT_ID')
|
| 59 |
+
GITHUB_CLIENT_SECRET = os.environ.get('GITHUB_CLIENT_SECRET')
|
| 60 |
+
GITHUB_REDIRECT_URI = os.environ.get('GITHUB_OAUTH_BACKEND_REDIRECT', 'http://127.0.0.1:5000/auth/github/callback')
|
| 61 |
+
|
| 62 |
+
# GitHub OAuth Functions
|
| 63 |
+
def generate_github_auth_url():
|
| 64 |
+
"""Generate GitHub OAuth authorization URL"""
|
| 65 |
+
params = {
|
| 66 |
+
'client_id': GITHUB_CLIENT_ID,
|
| 67 |
+
'redirect_uri': GITHUB_REDIRECT_URI,
|
| 68 |
+
'scope': 'user:email',
|
| 69 |
+
'state': str(uuid.uuid4()) # Simple CSRF protection
|
| 70 |
+
}
|
| 71 |
+
return f"https://github.com/login/oauth/authorize?{urlencode(params)}"
|
| 72 |
+
|
| 73 |
+
def exchange_code_for_token(code):
|
| 74 |
+
"""Exchange authorization code for access token"""
|
| 75 |
+
data = {
|
| 76 |
+
'client_id': GITHUB_CLIENT_ID,
|
| 77 |
+
'client_secret': GITHUB_CLIENT_SECRET,
|
| 78 |
+
'code': code,
|
| 79 |
+
'redirect_uri': GITHUB_REDIRECT_URI,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
headers = {
|
| 83 |
+
'Accept': 'application/json'
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
response = requests.post('https://github.com/login/oauth/access_token',
|
| 87 |
+
data=data, headers=headers)
|
| 88 |
+
|
| 89 |
+
if response.status_code == 200:
|
| 90 |
+
result = response.json()
|
| 91 |
+
return result.get('access_token')
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
def get_github_user_info(access_token):
|
| 95 |
+
"""Get user information from GitHub API"""
|
| 96 |
+
headers = {
|
| 97 |
+
'Authorization': f'token {access_token}',
|
| 98 |
+
'Accept': 'application/json'
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
response = requests.get('https://api.github.com/user', headers=headers)
|
| 102 |
+
|
| 103 |
+
if response.status_code == 200:
|
| 104 |
+
return response.json()
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
def get_github_user_email(access_token):
|
| 108 |
+
"""Get user's primary email from GitHub API"""
|
| 109 |
+
headers = {
|
| 110 |
+
'Authorization': f'token {access_token}',
|
| 111 |
+
'Accept': 'application/json'
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
response = requests.get('https://api.github.com/user/emails', headers=headers)
|
| 115 |
+
|
| 116 |
+
if response.status_code == 200:
|
| 117 |
+
emails = response.json()
|
| 118 |
+
# Find primary email
|
| 119 |
+
for email in emails:
|
| 120 |
+
if email.get('primary') and email.get('verified'):
|
| 121 |
+
return email.get('email')
|
| 122 |
+
# If no primary verified email, use the first one
|
| 123 |
+
if emails:
|
| 124 |
+
return emails[0].get('email')
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
# Email validation function
|
| 128 |
+
def validate_email_format(email):
|
| 129 |
+
"""Validate email format with flexible rules"""
|
| 130 |
+
import re
|
| 131 |
+
basic_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
| 132 |
+
if re.match(basic_pattern, email):
|
| 133 |
+
return None
|
| 134 |
+
return "Invalid email format"
|
| 135 |
+
|
| 136 |
+
# Routes
|
| 137 |
+
@app.route('/')
|
| 138 |
+
def index():
|
| 139 |
+
if current_user.is_authenticated:
|
| 140 |
+
return redirect(url_for('profile'))
|
| 141 |
+
return redirect(url_for('signin'))
|
| 142 |
+
|
| 143 |
+
@app.route('/signin', methods=['GET', 'POST'])
|
| 144 |
+
def signin():
|
| 145 |
+
if current_user.is_authenticated:
|
| 146 |
+
return redirect(url_for('profile'))
|
| 147 |
+
|
| 148 |
+
if request.method == 'POST':
|
| 149 |
+
email = request.form.get('email', '').strip()
|
| 150 |
+
password = request.form.get('password', '')
|
| 151 |
+
|
| 152 |
+
if not email or not password:
|
| 153 |
+
flash('Please enter both email and password.', 'error')
|
| 154 |
+
return render_template('signin.html')
|
| 155 |
+
|
| 156 |
+
user = User.query.filter_by(email=email).first()
|
| 157 |
+
|
| 158 |
+
if user and user.check_password(password):
|
| 159 |
+
# Check if user should be admin
|
| 160 |
+
admin_email = os.environ.get('ADMIN_EMAIL')
|
| 161 |
+
if admin_email and email.lower() == admin_email.lower():
|
| 162 |
+
if not user.is_admin:
|
| 163 |
+
user.is_admin = True
|
| 164 |
+
user.role = 'Admin'
|
| 165 |
+
db.session.commit()
|
| 166 |
+
flash('Admin access granted!', 'success')
|
| 167 |
+
|
| 168 |
+
login_user(user)
|
| 169 |
+
next_page = request.args.get('next')
|
| 170 |
+
return redirect(next_page or url_for('profile'))
|
| 171 |
+
else:
|
| 172 |
+
flash('Invalid email or password.', 'error')
|
| 173 |
+
|
| 174 |
+
return render_template('signin.html')
|
| 175 |
+
|
| 176 |
+
@app.route('/signup', methods=['GET', 'POST'])
|
| 177 |
+
def signup():
|
| 178 |
+
if current_user.is_authenticated:
|
| 179 |
+
return redirect(url_for('profile'))
|
| 180 |
+
|
| 181 |
+
if request.method == 'POST':
|
| 182 |
+
name = request.form.get('name', '').strip()
|
| 183 |
+
email = request.form.get('email', '').strip()
|
| 184 |
+
password = request.form.get('password', '')
|
| 185 |
+
password_confirm = request.form.get('password_confirm', '')
|
| 186 |
+
|
| 187 |
+
# Validation
|
| 188 |
+
errors = []
|
| 189 |
+
|
| 190 |
+
if not name:
|
| 191 |
+
errors.append('Name is required')
|
| 192 |
+
|
| 193 |
+
if not email:
|
| 194 |
+
errors.append('Email is required')
|
| 195 |
+
else:
|
| 196 |
+
email_error = validate_email_format(email)
|
| 197 |
+
if email_error:
|
| 198 |
+
errors.append(email_error)
|
| 199 |
+
|
| 200 |
+
if not password:
|
| 201 |
+
errors.append('Password is required')
|
| 202 |
+
elif len(password) < 8:
|
| 203 |
+
errors.append('Password must be at least 8 characters long')
|
| 204 |
+
|
| 205 |
+
if not password_confirm:
|
| 206 |
+
errors.append('Please confirm your password')
|
| 207 |
+
elif password != password_confirm:
|
| 208 |
+
errors.append('Passwords do not match')
|
| 209 |
+
|
| 210 |
+
# Check if email already exists
|
| 211 |
+
if email and User.query.filter_by(email=email).first():
|
| 212 |
+
errors.append('Email already registered')
|
| 213 |
+
|
| 214 |
+
if errors:
|
| 215 |
+
for error in errors:
|
| 216 |
+
flash(error, 'error')
|
| 217 |
+
return render_template('signup.html')
|
| 218 |
+
|
| 219 |
+
# Create new user
|
| 220 |
+
new_user = User(
|
| 221 |
+
id=uuid.uuid4(),
|
| 222 |
+
name=name,
|
| 223 |
+
email=email
|
| 224 |
+
)
|
| 225 |
+
new_user.set_password(password)
|
| 226 |
+
|
| 227 |
+
# Check if user should be admin
|
| 228 |
+
admin_email = os.environ.get('ADMIN_EMAIL')
|
| 229 |
+
if admin_email and email.lower() == admin_email.lower():
|
| 230 |
+
new_user.is_admin = True
|
| 231 |
+
new_user.role = 'Admin'
|
| 232 |
+
|
| 233 |
+
try:
|
| 234 |
+
db.session.add(new_user)
|
| 235 |
+
db.session.commit()
|
| 236 |
+
|
| 237 |
+
if new_user.is_admin:
|
| 238 |
+
flash('Admin account created successfully! Please sign in.', 'success')
|
| 239 |
+
else:
|
| 240 |
+
flash('Account created successfully! Please sign in.', 'success')
|
| 241 |
+
return redirect(url_for('signin'))
|
| 242 |
+
except Exception as e:
|
| 243 |
+
db.session.rollback()
|
| 244 |
+
flash('An error occurred while creating your account. Please try again.', 'error')
|
| 245 |
+
|
| 246 |
+
return render_template('signup.html')
|
| 247 |
+
|
| 248 |
+
@app.route('/logout')
|
| 249 |
+
@login_required
|
| 250 |
+
def logout():
|
| 251 |
+
logout_user()
|
| 252 |
+
flash('You have been logged out successfully.', 'success')
|
| 253 |
+
return redirect(url_for('signin'))
|
| 254 |
+
|
| 255 |
+
@app.route('/profile')
|
| 256 |
+
@login_required
|
| 257 |
+
def profile():
|
| 258 |
+
# Check if user has a profile
|
| 259 |
+
intro = Introduction.query.filter_by(user_id=current_user.id).first()
|
| 260 |
+
has_profile = intro is not None
|
| 261 |
+
|
| 262 |
+
if has_profile:
|
| 263 |
+
# Get all profile data
|
| 264 |
+
summary = ProfileSummary.query.filter_by(user_id=current_user.id).first()
|
| 265 |
+
work_experiences = WorkExperience.query.filter_by(user_id=current_user.id).order_by(WorkExperience.order).all()
|
| 266 |
+
projects = Project.query.filter_by(user_id=current_user.id).order_by(Project.order).all()
|
| 267 |
+
educations = Education.query.filter_by(user_id=current_user.id).order_by(Education.order).all()
|
| 268 |
+
skills = Skill.query.filter_by(user_id=current_user.id).order_by(Skill.order).all()
|
| 269 |
+
achievements = Achievement.query.filter_by(user_id=current_user.id).order_by(Achievement.order).all()
|
| 270 |
+
|
| 271 |
+
# Get section order if exists
|
| 272 |
+
section_order_obj = ProfileSectionOrder.query.filter_by(user_id=current_user.id).first()
|
| 273 |
+
section_order = section_order_obj.section_order if section_order_obj else [
|
| 274 |
+
'introduction', 'profile_summary', 'work_experience',
|
| 275 |
+
'projects', 'education', 'skills', 'achievements'
|
| 276 |
+
]
|
| 277 |
+
|
| 278 |
+
return render_template('profile.html',
|
| 279 |
+
has_profile=True,
|
| 280 |
+
intro=intro,
|
| 281 |
+
summary=summary,
|
| 282 |
+
work_experiences=work_experiences,
|
| 283 |
+
projects=projects,
|
| 284 |
+
educations=educations,
|
| 285 |
+
skills=skills,
|
| 286 |
+
achievements=achievements,
|
| 287 |
+
section_order=section_order)
|
| 288 |
+
else:
|
| 289 |
+
return render_template('profile.html', has_profile=False)
|
| 290 |
+
|
| 291 |
+
@app.route('/forgot-password')
|
| 292 |
+
def forgot_password():
|
| 293 |
+
return render_template('forgot_password.html')
|
| 294 |
+
|
| 295 |
+
# GitHub OAuth Routes
|
| 296 |
+
@app.route('/auth/github')
|
| 297 |
+
def github_auth():
|
| 298 |
+
"""Initiate GitHub OAuth flow"""
|
| 299 |
+
if not GITHUB_CLIENT_ID or not GITHUB_CLIENT_SECRET:
|
| 300 |
+
flash('GitHub OAuth is not configured. Please check your environment variables.', 'error')
|
| 301 |
+
return redirect(url_for('signin'))
|
| 302 |
+
|
| 303 |
+
auth_url = generate_github_auth_url()
|
| 304 |
+
return redirect(auth_url)
|
| 305 |
+
|
| 306 |
+
@app.route('/api/auth/github/callback')
|
| 307 |
+
def github_callback():
|
| 308 |
+
"""Handle GitHub OAuth callback"""
|
| 309 |
+
# Check for errors
|
| 310 |
+
error = request.args.get('error')
|
| 311 |
+
if error:
|
| 312 |
+
flash(f'GitHub authentication failed: {error}', 'error')
|
| 313 |
+
return redirect(url_for('signin'))
|
| 314 |
+
|
| 315 |
+
# Get authorization code and state
|
| 316 |
+
code = request.args.get('code')
|
| 317 |
+
state = request.args.get('state')
|
| 318 |
+
|
| 319 |
+
if not code:
|
| 320 |
+
flash('Authorization code not received from GitHub.', 'error')
|
| 321 |
+
return redirect(url_for('signin'))
|
| 322 |
+
|
| 323 |
+
# Exchange code for access token
|
| 324 |
+
access_token = exchange_code_for_token(code)
|
| 325 |
+
if not access_token:
|
| 326 |
+
flash('Failed to exchange authorization code for access token.', 'error')
|
| 327 |
+
return redirect(url_for('signin'))
|
| 328 |
+
|
| 329 |
+
# Get user information from GitHub
|
| 330 |
+
github_user = get_github_user_info(access_token)
|
| 331 |
+
if not github_user:
|
| 332 |
+
flash('Failed to retrieve user information from GitHub.', 'error')
|
| 333 |
+
return redirect(url_for('signin'))
|
| 334 |
+
|
| 335 |
+
# Get user email
|
| 336 |
+
email = get_github_user_email(access_token)
|
| 337 |
+
if not email:
|
| 338 |
+
flash('Could not retrieve email from GitHub. Please ensure your email is public and verified.', 'error')
|
| 339 |
+
return redirect(url_for('signin'))
|
| 340 |
+
|
| 341 |
+
# Check if user exists
|
| 342 |
+
user = User.query.filter_by(email=email).first()
|
| 343 |
+
|
| 344 |
+
if user:
|
| 345 |
+
# Existing user - check if they should be admin
|
| 346 |
+
admin_email = os.environ.get('ADMIN_EMAIL')
|
| 347 |
+
if admin_email and email.lower() == admin_email.lower():
|
| 348 |
+
if not user.is_admin:
|
| 349 |
+
user.is_admin = True
|
| 350 |
+
user.role = 'Admin'
|
| 351 |
+
db.session.commit()
|
| 352 |
+
flash('Admin access granted!', 'success')
|
| 353 |
+
|
| 354 |
+
login_user(user)
|
| 355 |
+
flash(f'Welcome back, {user.name}!', 'success')
|
| 356 |
+
return redirect(url_for('profile'))
|
| 357 |
+
else:
|
| 358 |
+
# New user - create account
|
| 359 |
+
# Generate a random password for GitHub users
|
| 360 |
+
import secrets
|
| 361 |
+
random_password = secrets.token_urlsafe(32)
|
| 362 |
+
|
| 363 |
+
new_user = User(
|
| 364 |
+
id=uuid.uuid4(),
|
| 365 |
+
name=github_user.get('name', github_user.get('login', 'GitHub User')),
|
| 366 |
+
email=email
|
| 367 |
+
)
|
| 368 |
+
new_user.set_password(random_password)
|
| 369 |
+
|
| 370 |
+
# Check if user should be admin
|
| 371 |
+
admin_email = os.environ.get('ADMIN_EMAIL')
|
| 372 |
+
if admin_email and email.lower() == admin_email.lower():
|
| 373 |
+
new_user.is_admin = True
|
| 374 |
+
new_user.role = 'Admin'
|
| 375 |
+
|
| 376 |
+
try:
|
| 377 |
+
db.session.add(new_user)
|
| 378 |
+
db.session.commit()
|
| 379 |
+
login_user(new_user)
|
| 380 |
+
|
| 381 |
+
if new_user.is_admin:
|
| 382 |
+
flash(f'Admin account created successfully! Welcome, {new_user.name}!', 'success')
|
| 383 |
+
else:
|
| 384 |
+
flash(f'Account created successfully! Welcome, {new_user.name}!', 'success')
|
| 385 |
+
return redirect(url_for('profile'))
|
| 386 |
+
except Exception as e:
|
| 387 |
+
db.session.rollback()
|
| 388 |
+
flash('Failed to create account. Please try again.', 'error')
|
| 389 |
+
return redirect(url_for('signin'))
|
| 390 |
+
|
| 391 |
+
# Profile Creation Routes
|
| 392 |
+
@app.route('/profile/create', methods=['GET', 'POST'])
|
| 393 |
+
@login_required
|
| 394 |
+
def create_profile():
|
| 395 |
+
"""Start profile creation process"""
|
| 396 |
+
# Check if user already has a profile
|
| 397 |
+
if current_user.introduction:
|
| 398 |
+
flash('You already have a profile. View or edit it from your profile page.', 'info')
|
| 399 |
+
return redirect(url_for('profile'))
|
| 400 |
+
|
| 401 |
+
return render_template('create_profile.html')
|
| 402 |
+
|
| 403 |
+
@app.route('/profile/create/introduction', methods=['GET', 'POST'])
|
| 404 |
+
@login_required
|
| 405 |
+
def create_introduction():
|
| 406 |
+
"""Create introduction section"""
|
| 407 |
+
form_data = {}
|
| 408 |
+
form_errors = {}
|
| 409 |
+
|
| 410 |
+
if request.method == 'POST':
|
| 411 |
+
name = request.form.get('name', '').strip()
|
| 412 |
+
email = request.form.get('email', '').strip()
|
| 413 |
+
phone = request.form.get('phone', '').strip()
|
| 414 |
+
linkedin = request.form.get('linkedin', '').strip()
|
| 415 |
+
github = request.form.get('github', '').strip()
|
| 416 |
+
website = request.form.get('website', '').strip()
|
| 417 |
+
|
| 418 |
+
# Store form data
|
| 419 |
+
form_data = {
|
| 420 |
+
'name': name,
|
| 421 |
+
'email': email,
|
| 422 |
+
'phone': phone,
|
| 423 |
+
'linkedin': linkedin,
|
| 424 |
+
'github': github,
|
| 425 |
+
'website': website
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
# Validation
|
| 429 |
+
if not name:
|
| 430 |
+
form_errors['name'] = ['Name is required']
|
| 431 |
+
if not email:
|
| 432 |
+
form_errors['email'] = ['Email is required']
|
| 433 |
+
elif not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
|
| 434 |
+
form_errors['email'] = ['Invalid email format']
|
| 435 |
+
if not phone:
|
| 436 |
+
form_errors['phone'] = ['Phone is required']
|
| 437 |
+
|
| 438 |
+
if form_errors:
|
| 439 |
+
return render_template('create_introduction.html',
|
| 440 |
+
form_data=form_data,
|
| 441 |
+
form_errors=form_errors)
|
| 442 |
+
|
| 443 |
+
# Create introduction
|
| 444 |
+
introduction = Introduction(
|
| 445 |
+
user_id=current_user.id,
|
| 446 |
+
name=name,
|
| 447 |
+
email=email,
|
| 448 |
+
phone=phone,
|
| 449 |
+
linkedin=linkedin or None,
|
| 450 |
+
github=github or None,
|
| 451 |
+
website=website or None
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
try:
|
| 455 |
+
db.session.add(introduction)
|
| 456 |
+
db.session.commit()
|
| 457 |
+
return redirect(url_for('create_profile_summary'))
|
| 458 |
+
except Exception as e:
|
| 459 |
+
db.session.rollback()
|
| 460 |
+
flash('An error occurred while saving your introduction. Please try again.', 'error')
|
| 461 |
+
|
| 462 |
+
return render_template('create_introduction.html',
|
| 463 |
+
form_data=form_data,
|
| 464 |
+
form_errors=form_errors)
|
| 465 |
+
|
| 466 |
+
@app.route('/profile/create/profile-summary', methods=['GET', 'POST'])
|
| 467 |
+
@login_required
|
| 468 |
+
def create_profile_summary():
|
| 469 |
+
"""Create profile summary section"""
|
| 470 |
+
form_data = {}
|
| 471 |
+
form_errors = {}
|
| 472 |
+
|
| 473 |
+
if not current_user.introduction:
|
| 474 |
+
flash('Please complete your introduction first.', 'error')
|
| 475 |
+
return redirect(url_for('create_introduction'))
|
| 476 |
+
|
| 477 |
+
if request.method == 'POST':
|
| 478 |
+
summary = request.form.get('summary', '').strip()
|
| 479 |
+
|
| 480 |
+
# Store form data
|
| 481 |
+
form_data = {
|
| 482 |
+
'summary': summary
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
# Validation
|
| 486 |
+
if not summary:
|
| 487 |
+
form_errors['summary'] = ['Profile summary is required']
|
| 488 |
+
|
| 489 |
+
if form_errors:
|
| 490 |
+
return render_template('create_profile_summary.html',
|
| 491 |
+
form_data=form_data,
|
| 492 |
+
form_errors=form_errors)
|
| 493 |
+
|
| 494 |
+
# Create or update profile summary
|
| 495 |
+
profile_summary = ProfileSummary.query.filter_by(user_id=current_user.id).first()
|
| 496 |
+
if not profile_summary:
|
| 497 |
+
profile_summary = ProfileSummary(user_id=current_user.id)
|
| 498 |
+
|
| 499 |
+
profile_summary.summary = summary
|
| 500 |
+
profile_summary.ai_generated = False
|
| 501 |
+
|
| 502 |
+
try:
|
| 503 |
+
db.session.add(profile_summary)
|
| 504 |
+
db.session.commit()
|
| 505 |
+
return redirect(url_for('create_work_experience'))
|
| 506 |
+
except Exception as e:
|
| 507 |
+
db.session.rollback()
|
| 508 |
+
flash('An error occurred while saving your profile summary. Please try again.', 'error')
|
| 509 |
+
|
| 510 |
+
# For GET request or if returning from POST with error
|
| 511 |
+
# Get existing summary if any
|
| 512 |
+
existing_summary = ProfileSummary.query.filter_by(user_id=current_user.id).first()
|
| 513 |
+
if existing_summary:
|
| 514 |
+
form_data['summary'] = existing_summary.summary
|
| 515 |
+
|
| 516 |
+
return render_template('create_profile_summary.html',
|
| 517 |
+
form_data=form_data,
|
| 518 |
+
form_errors=form_errors)
|
| 519 |
+
|
| 520 |
+
@app.route('/profile/create/generate-summary', methods=['POST'])
|
| 521 |
+
@login_required
|
| 522 |
+
def generate_ai_summary():
|
| 523 |
+
"""Generate AI-powered profile summary using OpenAI"""
|
| 524 |
+
if not current_user.introduction:
|
| 525 |
+
return jsonify({'error': 'Please complete your introduction first'}), 400
|
| 526 |
+
|
| 527 |
+
try:
|
| 528 |
+
# Get user's introduction and other relevant info
|
| 529 |
+
intro = current_user.introduction
|
| 530 |
+
|
| 531 |
+
# Get additional profile data if available
|
| 532 |
+
work_experiences = WorkExperience.query.filter_by(user_id=current_user.id).all()
|
| 533 |
+
projects = Project.query.filter_by(user_id=current_user.id).all()
|
| 534 |
+
educations = Education.query.filter_by(user_id=current_user.id).all()
|
| 535 |
+
skills = Skill.query.filter_by(user_id=current_user.id).all()
|
| 536 |
+
|
| 537 |
+
# Prepare context for AI
|
| 538 |
+
context = f"""
|
| 539 |
+
Personal Information:
|
| 540 |
+
- Name: {intro.name}
|
| 541 |
+
- Email: {intro.email}
|
| 542 |
+
- Phone: {intro.phone}
|
| 543 |
+
- LinkedIn: {intro.linkedin or 'Not provided'}
|
| 544 |
+
- GitHub: {intro.github or 'Not provided'}
|
| 545 |
+
- Website: {intro.website or 'Not provided'}
|
| 546 |
+
"""
|
| 547 |
+
|
| 548 |
+
if work_experiences:
|
| 549 |
+
context += "\n\nWork Experience:\n"
|
| 550 |
+
for exp in work_experiences[:3]: # Limit to first 3 experiences
|
| 551 |
+
context += f"- {exp.title} at {exp.organization} ({exp.start_month}/{exp.start_year} - {'Present' if not exp.end_month else f'{exp.end_month}/{exp.end_year}'})\n"
|
| 552 |
+
|
| 553 |
+
if educations:
|
| 554 |
+
context += "\nEducation:\n"
|
| 555 |
+
for edu in educations[:2]: # Limit to first 2 educations
|
| 556 |
+
context += f"- {edu.title} at {edu.organization} ({edu.start_month}/{edu.start_year} - {'Present' if not edu.end_month else f'{edu.end_month}/{edu.end_year}'})\n"
|
| 557 |
+
|
| 558 |
+
if skills:
|
| 559 |
+
skill_list = [s.skill for s in skills[:10]] # Limit to first 10 skills
|
| 560 |
+
context += f"\nSkills: {', '.join(skill_list)}"
|
| 561 |
+
|
| 562 |
+
# Using OpenAI API directly
|
| 563 |
+
def generate_with_openai():
|
| 564 |
+
from openai import OpenAI
|
| 565 |
+
client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY'))
|
| 566 |
+
|
| 567 |
+
prompt = f"""
|
| 568 |
+
Create a professional profile summary based on this information:
|
| 569 |
+
|
| 570 |
+
{context}
|
| 571 |
+
|
| 572 |
+
Requirements:
|
| 573 |
+
- Write in third person
|
| 574 |
+
- Keep it concise (3-5 sentences)
|
| 575 |
+
- Highlight professional strengths and achievements
|
| 576 |
+
- Make it engaging and professional
|
| 577 |
+
- Focus on what makes this person unique
|
| 578 |
+
"""
|
| 579 |
+
|
| 580 |
+
response = client.chat.completions.create(
|
| 581 |
+
model=os.environ.get('OPENAI_MODEL', 'gpt-4o'),
|
| 582 |
+
messages=[
|
| 583 |
+
{"role": "system", "content": "You are an expert resume writer and career coach. Write compelling professional profiles that stand out."},
|
| 584 |
+
{"role": "user", "content": prompt}
|
| 585 |
+
],
|
| 586 |
+
max_tokens=250,
|
| 587 |
+
temperature=0.8
|
| 588 |
+
)
|
| 589 |
+
return response.choices[0].message.content.strip()
|
| 590 |
+
|
| 591 |
+
# Generate the summary
|
| 592 |
+
ai_summary = generate_with_openai()
|
| 593 |
+
|
| 594 |
+
# Save the AI-generated summary
|
| 595 |
+
profile_summary = ProfileSummary.query.filter_by(user_id=current_user.id).first()
|
| 596 |
+
if not profile_summary:
|
| 597 |
+
profile_summary = ProfileSummary(user_id=current_user.id)
|
| 598 |
+
|
| 599 |
+
profile_summary.summary = ai_summary
|
| 600 |
+
profile_summary.ai_generated = True
|
| 601 |
+
|
| 602 |
+
db.session.add(profile_summary)
|
| 603 |
+
db.session.commit()
|
| 604 |
+
|
| 605 |
+
return jsonify({
|
| 606 |
+
'success': True,
|
| 607 |
+
'summary': ai_summary,
|
| 608 |
+
'message': 'Profile summary generated successfully!'
|
| 609 |
+
})
|
| 610 |
+
|
| 611 |
+
except Exception as e:
|
| 612 |
+
db.session.rollback()
|
| 613 |
+
return jsonify({'success': False, 'error': f'Failed to generate summary: {str(e)}'}), 500
|
| 614 |
+
|
| 615 |
+
@app.route('/profile/create/work-experience', methods=['GET', 'POST'])
|
| 616 |
+
@login_required
|
| 617 |
+
def create_work_experience():
|
| 618 |
+
"""Create work experience section"""
|
| 619 |
+
if not current_user.introduction:
|
| 620 |
+
flash('Please complete your introduction first.', 'error')
|
| 621 |
+
return redirect(url_for('create_introduction'))
|
| 622 |
+
|
| 623 |
+
if request.method == 'POST':
|
| 624 |
+
# Clear existing work experiences for this user
|
| 625 |
+
WorkExperience.query.filter_by(user_id=current_user.id).delete()
|
| 626 |
+
|
| 627 |
+
# Get form data
|
| 628 |
+
organizations = request.form.getlist('organization[]')
|
| 629 |
+
titles = request.form.getlist('title[]')
|
| 630 |
+
start_months = request.form.getlist('start_month[]')
|
| 631 |
+
start_years = request.form.getlist('start_year[]')
|
| 632 |
+
end_months = request.form.getlist('end_month[]')
|
| 633 |
+
end_years = request.form.getlist('end_year[]')
|
| 634 |
+
is_present_list = request.form.getlist('is_present[]')
|
| 635 |
+
remarks_list = request.form.getlist('remarks[]')
|
| 636 |
+
|
| 637 |
+
# Find the maximum length among all lists to handle inconsistent lengths
|
| 638 |
+
max_length = max(len(organizations), len(titles), len(start_months),
|
| 639 |
+
len(start_years), len(end_months), len(end_years),
|
| 640 |
+
len(remarks_list))
|
| 641 |
+
|
| 642 |
+
# Save each work experience
|
| 643 |
+
for i in range(max_length):
|
| 644 |
+
org = organizations[i] if i < len(organizations) else ''
|
| 645 |
+
title = titles[i] if i < len(titles) else ''
|
| 646 |
+
|
| 647 |
+
if org.strip(): # Only save if organization is provided
|
| 648 |
+
work_exp = WorkExperience(
|
| 649 |
+
user_id=current_user.id,
|
| 650 |
+
organization=org.strip(),
|
| 651 |
+
title=title.strip(),
|
| 652 |
+
start_month=int(start_months[i]) if i < len(start_months) and start_months[i] else None,
|
| 653 |
+
start_year=int(start_years[i]) if i < len(start_years) and start_years[i] else None,
|
| 654 |
+
end_month=int(end_months[i]) if i < len(end_months) and end_months[i] and str(i) not in is_present_list else None,
|
| 655 |
+
end_year=int(end_years[i]) if i < len(end_years) and end_years[i] and str(i) not in is_present_list else None,
|
| 656 |
+
remarks=remarks_list[i].strip() if i < len(remarks_list) and remarks_list[i] else None,
|
| 657 |
+
order=i
|
| 658 |
+
)
|
| 659 |
+
db.session.add(work_exp)
|
| 660 |
+
|
| 661 |
+
try:
|
| 662 |
+
db.session.commit()
|
| 663 |
+
flash('Work experience saved successfully.', 'success')
|
| 664 |
+
return redirect(url_for('create_projects'))
|
| 665 |
+
except Exception as e:
|
| 666 |
+
db.session.rollback()
|
| 667 |
+
flash('An error occurred while saving work experience.', 'error')
|
| 668 |
+
|
| 669 |
+
# GET request - show form
|
| 670 |
+
form_data = {
|
| 671 |
+
'work_experiences': WorkExperience.query.filter_by(user_id=current_user.id).order_by(WorkExperience.order).all()
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
return render_template('create_work_experience.html',
|
| 675 |
+
form_data=form_data,
|
| 676 |
+
current_year=datetime.now().year)
|
| 677 |
+
|
| 678 |
+
@app.route('/profile/create/projects', methods=['GET', 'POST'])
|
| 679 |
+
@login_required
|
| 680 |
+
def create_projects():
|
| 681 |
+
"""Create projects section"""
|
| 682 |
+
if not current_user.introduction:
|
| 683 |
+
flash('Please complete your introduction first.', 'error')
|
| 684 |
+
return redirect(url_for('create_introduction'))
|
| 685 |
+
|
| 686 |
+
if request.method == 'POST':
|
| 687 |
+
# Clear existing projects for this user
|
| 688 |
+
Project.query.filter_by(user_id=current_user.id).delete()
|
| 689 |
+
|
| 690 |
+
# Get form data
|
| 691 |
+
organizations = request.form.getlist('organization[]')
|
| 692 |
+
titles = request.form.getlist('title[]')
|
| 693 |
+
start_months = request.form.getlist('start_month[]')
|
| 694 |
+
start_years = request.form.getlist('start_year[]')
|
| 695 |
+
end_months = request.form.getlist('end_month[]')
|
| 696 |
+
end_years = request.form.getlist('end_year[]')
|
| 697 |
+
is_present_list = request.form.getlist('is_present[]')
|
| 698 |
+
remarks_list = request.form.getlist('remarks[]')
|
| 699 |
+
|
| 700 |
+
# Find the maximum length among all lists to handle inconsistent lengths
|
| 701 |
+
max_length = max(len(organizations), len(titles), len(start_months),
|
| 702 |
+
len(start_years), len(end_months), len(end_years),
|
| 703 |
+
len(remarks_list))
|
| 704 |
+
|
| 705 |
+
# Save each project
|
| 706 |
+
for i in range(max_length):
|
| 707 |
+
title = titles[i] if i < len(titles) else ''
|
| 708 |
+
org = organizations[i] if i < len(organizations) else ''
|
| 709 |
+
|
| 710 |
+
if title.strip(): # Only save if title is provided
|
| 711 |
+
project = Project(
|
| 712 |
+
user_id=current_user.id,
|
| 713 |
+
organization=org.strip() if org else None,
|
| 714 |
+
title=title.strip(),
|
| 715 |
+
start_month=int(start_months[i]) if i < len(start_months) and start_months[i] else None,
|
| 716 |
+
start_year=int(start_years[i]) if i < len(start_years) and start_years[i] else None,
|
| 717 |
+
end_month=int(end_months[i]) if i < len(end_months) and end_months[i] and str(i) not in is_present_list else None,
|
| 718 |
+
end_year=int(end_years[i]) if i < len(end_years) and end_years[i] and str(i) not in is_present_list else None,
|
| 719 |
+
remarks=remarks_list[i].strip() if i < len(remarks_list) and remarks_list[i] else None,
|
| 720 |
+
order=i
|
| 721 |
+
)
|
| 722 |
+
db.session.add(project)
|
| 723 |
+
|
| 724 |
+
try:
|
| 725 |
+
db.session.commit()
|
| 726 |
+
flash('Projects saved successfully.', 'success')
|
| 727 |
+
return redirect(url_for('create_education'))
|
| 728 |
+
except Exception as e:
|
| 729 |
+
db.session.rollback()
|
| 730 |
+
flash('An error occurred while saving projects.', 'error')
|
| 731 |
+
|
| 732 |
+
# GET request - show form
|
| 733 |
+
form_data = {
|
| 734 |
+
'projects': Project.query.filter_by(user_id=current_user.id).order_by(Project.order).all()
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
return render_template('create_projects.html',
|
| 738 |
+
form_data=form_data,
|
| 739 |
+
current_year=datetime.now().year)
|
| 740 |
+
|
| 741 |
+
@app.route('/profile/create/education', methods=['GET', 'POST'])
|
| 742 |
+
@login_required
|
| 743 |
+
def create_education():
|
| 744 |
+
"""Create education section"""
|
| 745 |
+
if not current_user.introduction:
|
| 746 |
+
flash('Please complete your introduction first.', 'error')
|
| 747 |
+
return redirect(url_for('create_introduction'))
|
| 748 |
+
|
| 749 |
+
if request.method == 'POST':
|
| 750 |
+
# Clear existing education for this user
|
| 751 |
+
Education.query.filter_by(user_id=current_user.id).delete()
|
| 752 |
+
|
| 753 |
+
# Get form data
|
| 754 |
+
organizations = request.form.getlist('organization[]')
|
| 755 |
+
titles = request.form.getlist('title[]')
|
| 756 |
+
start_months = request.form.getlist('start_month[]')
|
| 757 |
+
start_years = request.form.getlist('start_year[]')
|
| 758 |
+
end_months = request.form.getlist('end_month[]')
|
| 759 |
+
end_years = request.form.getlist('end_year[]')
|
| 760 |
+
is_present_list = request.form.getlist('is_present[]')
|
| 761 |
+
remarks_list = request.form.getlist('remarks[]')
|
| 762 |
+
|
| 763 |
+
# Find the maximum length among all lists to handle inconsistent lengths
|
| 764 |
+
max_length = max(len(organizations), len(titles), len(start_months),
|
| 765 |
+
len(start_years), len(end_months), len(end_years),
|
| 766 |
+
len(remarks_list))
|
| 767 |
+
|
| 768 |
+
# Save each education
|
| 769 |
+
for i in range(max_length):
|
| 770 |
+
org = organizations[i] if i < len(organizations) else ''
|
| 771 |
+
title = titles[i] if i < len(titles) else ''
|
| 772 |
+
|
| 773 |
+
if org.strip(): # Only save if organization is provided
|
| 774 |
+
education = Education(
|
| 775 |
+
user_id=current_user.id,
|
| 776 |
+
organization=org.strip(),
|
| 777 |
+
title=title.strip(),
|
| 778 |
+
start_month=int(start_months[i]) if i < len(start_months) and start_months[i] else None,
|
| 779 |
+
start_year=int(start_years[i]) if i < len(start_years) and start_years[i] else None,
|
| 780 |
+
end_month=int(end_months[i]) if i < len(end_months) and end_months[i] and str(i) not in is_present_list else None,
|
| 781 |
+
end_year=int(end_years[i]) if i < len(end_years) and end_years[i] and str(i) not in is_present_list else None,
|
| 782 |
+
remarks=remarks_list[i].strip() if i < len(remarks_list) and remarks_list[i] else None,
|
| 783 |
+
order=i
|
| 784 |
+
)
|
| 785 |
+
db.session.add(education)
|
| 786 |
+
|
| 787 |
+
try:
|
| 788 |
+
db.session.commit()
|
| 789 |
+
flash('Education saved successfully.', 'success')
|
| 790 |
+
return redirect(url_for('create_skills'))
|
| 791 |
+
except Exception as e:
|
| 792 |
+
db.session.rollback()
|
| 793 |
+
flash('An error occurred while saving education.', 'error')
|
| 794 |
+
|
| 795 |
+
# GET request - show form
|
| 796 |
+
form_data = {
|
| 797 |
+
'educations': Education.query.filter_by(user_id=current_user.id).order_by(Education.order).all()
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
return render_template('create_education.html',
|
| 801 |
+
form_data=form_data,
|
| 802 |
+
current_year=datetime.now().year)
|
| 803 |
+
|
| 804 |
+
@app.route('/profile/create/skills', methods=['GET', 'POST'])
|
| 805 |
+
@login_required
|
| 806 |
+
def create_skills():
|
| 807 |
+
"""Create skills section"""
|
| 808 |
+
form_data = {}
|
| 809 |
+
form_errors = {}
|
| 810 |
+
|
| 811 |
+
if not current_user.introduction:
|
| 812 |
+
flash('Please complete your introduction first.', 'error')
|
| 813 |
+
return redirect(url_for('create_introduction'))
|
| 814 |
+
|
| 815 |
+
if request.method == 'POST':
|
| 816 |
+
# Get skills from form
|
| 817 |
+
skills_text = request.form.get('skills', '').strip()
|
| 818 |
+
|
| 819 |
+
# Store form data
|
| 820 |
+
form_data = {
|
| 821 |
+
'skills': skills_text,
|
| 822 |
+
'skills_preview': [skill.strip() for skill in skills_text.split(',') if skill.strip()] if skills_text else []
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
# Validation - skills are optional but if provided, they should be valid
|
| 826 |
+
if skills_text and len(skills_text.split(',')) > 50:
|
| 827 |
+
form_errors['skills'] = ['You can add up to 50 skills maximum']
|
| 828 |
+
|
| 829 |
+
if form_errors:
|
| 830 |
+
return render_template('create_skills.html',
|
| 831 |
+
form_data=form_data,
|
| 832 |
+
form_errors=form_errors)
|
| 833 |
+
|
| 834 |
+
# Clear existing skills for this user
|
| 835 |
+
Skill.query.filter_by(user_id=current_user.id).delete()
|
| 836 |
+
|
| 837 |
+
if skills_text:
|
| 838 |
+
skills_list = [skill.strip() for skill in skills_text.split(',') if skill.strip()]
|
| 839 |
+
|
| 840 |
+
# Save each skill
|
| 841 |
+
for i, skill in enumerate(skills_list):
|
| 842 |
+
new_skill = Skill(
|
| 843 |
+
user_id=current_user.id,
|
| 844 |
+
skill=skill,
|
| 845 |
+
order=i
|
| 846 |
+
)
|
| 847 |
+
db.session.add(new_skill)
|
| 848 |
+
|
| 849 |
+
try:
|
| 850 |
+
db.session.commit()
|
| 851 |
+
flash('Skills saved successfully.', 'success')
|
| 852 |
+
return redirect(url_for('create_achievements'))
|
| 853 |
+
except Exception as e:
|
| 854 |
+
db.session.rollback()
|
| 855 |
+
flash('An error occurred while saving skills.', 'error')
|
| 856 |
+
|
| 857 |
+
# GET request - show form
|
| 858 |
+
existing_skills = Skill.query.filter_by(user_id=current_user.id).order_by(Skill.order).all()
|
| 859 |
+
skills_text = ', '.join([skill.skill for skill in existing_skills])
|
| 860 |
+
|
| 861 |
+
form_data = {
|
| 862 |
+
'skills': skills_text,
|
| 863 |
+
'skills_preview': [skill.skill for skill in existing_skills]
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
return render_template('create_skills.html',
|
| 867 |
+
form_data=form_data,
|
| 868 |
+
form_errors=form_errors)
|
| 869 |
+
|
| 870 |
+
@app.route('/profile/create/achievements', methods=['GET', 'POST'])
|
| 871 |
+
@login_required
|
| 872 |
+
def create_achievements():
|
| 873 |
+
"""Create achievements section"""
|
| 874 |
+
form_data = {}
|
| 875 |
+
form_errors = {}
|
| 876 |
+
|
| 877 |
+
if not current_user.introduction:
|
| 878 |
+
flash('Please complete your introduction first.', 'error')
|
| 879 |
+
return redirect(url_for('create_introduction'))
|
| 880 |
+
|
| 881 |
+
if request.method == 'POST':
|
| 882 |
+
# Get achievements from form
|
| 883 |
+
achievements_text = request.form.get('achievements', '').strip()
|
| 884 |
+
|
| 885 |
+
# Store form data
|
| 886 |
+
form_data = {
|
| 887 |
+
'achievements': achievements_text,
|
| 888 |
+
'achievements_preview': [achievement.strip() for achievement in achievements_text.split(',') if achievement.strip()] if achievements_text else []
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
# Validation - achievements are optional but if provided, they should be valid
|
| 892 |
+
if achievements_text and len(achievements_text.split(',')) > 50:
|
| 893 |
+
form_errors['achievements'] = ['You can add up to 50 achievements maximum']
|
| 894 |
+
|
| 895 |
+
if form_errors:
|
| 896 |
+
return render_template('create_achievements.html',
|
| 897 |
+
form_data=form_data,
|
| 898 |
+
form_errors=form_errors)
|
| 899 |
+
|
| 900 |
+
# Clear existing achievements for this user
|
| 901 |
+
Achievement.query.filter_by(user_id=current_user.id).delete()
|
| 902 |
+
|
| 903 |
+
if achievements_text:
|
| 904 |
+
achievements_list = [achievement.strip() for achievement in achievements_text.split(',') if achievement.strip()]
|
| 905 |
+
|
| 906 |
+
# Save each achievement
|
| 907 |
+
for i, achievement in enumerate(achievements_list):
|
| 908 |
+
new_achievement = Achievement(
|
| 909 |
+
user_id=current_user.id,
|
| 910 |
+
achievement=achievement,
|
| 911 |
+
order=i
|
| 912 |
+
)
|
| 913 |
+
db.session.add(new_achievement)
|
| 914 |
+
|
| 915 |
+
try:
|
| 916 |
+
db.session.commit()
|
| 917 |
+
flash('Achievements saved successfully.', 'success')
|
| 918 |
+
return redirect(url_for('create_preview'))
|
| 919 |
+
except Exception as e:
|
| 920 |
+
db.session.rollback()
|
| 921 |
+
flash('An error occurred while saving achievements.', 'error')
|
| 922 |
+
|
| 923 |
+
# GET request - show form
|
| 924 |
+
existing_achievements = Achievement.query.filter_by(user_id=current_user.id).order_by(Achievement.order).all()
|
| 925 |
+
achievements_text = ', '.join([achievement.achievement for achievement in existing_achievements])
|
| 926 |
+
|
| 927 |
+
form_data = {
|
| 928 |
+
'achievements': achievements_text,
|
| 929 |
+
'achievements_preview': [achievement.achievement for achievement in existing_achievements]
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
return render_template('create_achievements.html',
|
| 933 |
+
form_data=form_data,
|
| 934 |
+
form_errors=form_errors)
|
| 935 |
+
|
| 936 |
+
@app.route('/profile/create/preview', methods=['GET', 'POST'])
|
| 937 |
+
@login_required
|
| 938 |
+
def create_preview():
|
| 939 |
+
"""Preview and finalize profile"""
|
| 940 |
+
if not current_user.introduction:
|
| 941 |
+
flash('Please complete your introduction first.', 'error')
|
| 942 |
+
return redirect(url_for('create_introduction'))
|
| 943 |
+
|
| 944 |
+
if request.method == 'POST':
|
| 945 |
+
action = request.form.get('action')
|
| 946 |
+
|
| 947 |
+
if action == 'submit':
|
| 948 |
+
# Save section order
|
| 949 |
+
section_order = request.form.get('section_order', '[]')
|
| 950 |
+
try:
|
| 951 |
+
order_data = json.loads(section_order)
|
| 952 |
+
|
| 953 |
+
# Update or create section order
|
| 954 |
+
profile_order = ProfileSectionOrder.query.filter_by(user_id=current_user.id).first()
|
| 955 |
+
if not profile_order:
|
| 956 |
+
profile_order = ProfileSectionOrder(user_id=current_user.id)
|
| 957 |
+
|
| 958 |
+
profile_order.section_order = order_data
|
| 959 |
+
db.session.add(profile_order)
|
| 960 |
+
db.session.commit()
|
| 961 |
+
|
| 962 |
+
flash('Profile created successfully!', 'success')
|
| 963 |
+
return redirect(url_for('profile'))
|
| 964 |
+
|
| 965 |
+
except Exception as e:
|
| 966 |
+
db.session.rollback()
|
| 967 |
+
flash('An error occurred while saving your profile.', 'error')
|
| 968 |
+
|
| 969 |
+
elif action == 'clear':
|
| 970 |
+
# Delete all profile data
|
| 971 |
+
try:
|
| 972 |
+
# Delete all profile sections
|
| 973 |
+
if current_user.introduction:
|
| 974 |
+
db.session.delete(current_user.introduction)
|
| 975 |
+
if current_user.profile_summary:
|
| 976 |
+
db.session.delete(current_user.profile_summary)
|
| 977 |
+
if current_user.section_order:
|
| 978 |
+
db.session.delete(current_user.section_order)
|
| 979 |
+
|
| 980 |
+
# Delete collections
|
| 981 |
+
WorkExperience.query.filter_by(user_id=current_user.id).delete()
|
| 982 |
+
Project.query.filter_by(user_id=current_user.id).delete()
|
| 983 |
+
Education.query.filter_by(user_id=current_user.id).delete()
|
| 984 |
+
Skill.query.filter_by(user_id=current_user.id).delete()
|
| 985 |
+
Achievement.query.filter_by(user_id=current_user.id).delete()
|
| 986 |
+
|
| 987 |
+
db.session.commit()
|
| 988 |
+
flash('Profile cleared successfully.', 'success')
|
| 989 |
+
return redirect(url_for('profile'))
|
| 990 |
+
|
| 991 |
+
except Exception as e:
|
| 992 |
+
db.session.rollback()
|
| 993 |
+
flash('An error occurred while clearing your profile.', 'error')
|
| 994 |
+
|
| 995 |
+
# Get all profile data
|
| 996 |
+
intro = current_user.introduction
|
| 997 |
+
summary = current_user.profile_summary
|
| 998 |
+
work_experiences = WorkExperience.query.filter_by(user_id=current_user.id).order_by(WorkExperience.order).all()
|
| 999 |
+
projects = Project.query.filter_by(user_id=current_user.id).order_by(Project.order).all()
|
| 1000 |
+
educations = Education.query.filter_by(user_id=current_user.id).order_by(Education.order).all()
|
| 1001 |
+
skills = Skill.query.filter_by(user_id=current_user.id).order_by(Skill.order).all()
|
| 1002 |
+
achievements = Achievement.query.filter_by(user_id=current_user.id).order_by(Achievement.order).all()
|
| 1003 |
+
|
| 1004 |
+
# Get default section order
|
| 1005 |
+
default_order = ['introduction', 'profile_summary', 'work_experience', 'projects', 'education', 'skills', 'achievements']
|
| 1006 |
+
|
| 1007 |
+
return render_template('create_preview.html',
|
| 1008 |
+
intro=intro,
|
| 1009 |
+
summary=summary,
|
| 1010 |
+
work_experiences=work_experiences,
|
| 1011 |
+
projects=projects,
|
| 1012 |
+
educations=educations,
|
| 1013 |
+
skills=skills,
|
| 1014 |
+
achievements=achievements,
|
| 1015 |
+
default_order=default_order)
|
| 1016 |
+
|
| 1017 |
+
@app.route('/ping')
|
| 1018 |
+
def ping():
|
| 1019 |
+
return {"ok": True, "msg": "pong from app.py"}
|
| 1020 |
+
|
| 1021 |
+
@app.cli.command()
|
| 1022 |
+
def create_tables():
|
| 1023 |
+
"""Create database tables"""
|
| 1024 |
+
with app.app_context():
|
| 1025 |
+
db.create_all()
|
| 1026 |
+
print("Database tables created successfully!")
|
| 1027 |
+
|
| 1028 |
+
# Resume Generation Routes
|
| 1029 |
+
@app.route('/profile/generate-resume/<format_type>')
|
| 1030 |
+
@login_required
|
| 1031 |
+
def generate_resume(format_type):
|
| 1032 |
+
"""Generate resume in specified format"""
|
| 1033 |
+
# Check if user has profile data
|
| 1034 |
+
intro = Introduction.query.filter_by(user_id=current_user.id).first()
|
| 1035 |
+
if not intro:
|
| 1036 |
+
flash('Please create your profile first.', 'error')
|
| 1037 |
+
return redirect(url_for('profile'))
|
| 1038 |
+
|
| 1039 |
+
# Get all profile data
|
| 1040 |
+
summary = ProfileSummary.query.filter_by(user_id=current_user.id).first()
|
| 1041 |
+
work_experiences = WorkExperience.query.filter_by(user_id=current_user.id).order_by(WorkExperience.order).all()
|
| 1042 |
+
projects = Project.query.filter_by(user_id=current_user.id).order_by(Project.order).all()
|
| 1043 |
+
educations = Education.query.filter_by(user_id=current_user.id).order_by(Education.order).all()
|
| 1044 |
+
skills = Skill.query.filter_by(user_id=current_user.id).order_by(Skill.order).all()
|
| 1045 |
+
achievements = Achievement.query.filter_by(user_id=current_user.id).order_by(Achievement.order).all()
|
| 1046 |
+
|
| 1047 |
+
# Get section order
|
| 1048 |
+
section_order_obj = ProfileSectionOrder.query.filter_by(user_id=current_user.id).first()
|
| 1049 |
+
section_order = section_order_obj.section_order if section_order_obj else [
|
| 1050 |
+
'introduction', 'profile_summary', 'work_experience',
|
| 1051 |
+
'projects', 'education', 'skills', 'achievements'
|
| 1052 |
+
]
|
| 1053 |
+
|
| 1054 |
+
try:
|
| 1055 |
+
if format_type == 'word':
|
| 1056 |
+
return generate_word_resume(
|
| 1057 |
+
intro, summary, work_experiences, projects,
|
| 1058 |
+
educations, skills, achievements, section_order
|
| 1059 |
+
)
|
| 1060 |
+
elif format_type == 'pdf-standard':
|
| 1061 |
+
return generate_pdf_resume(
|
| 1062 |
+
intro, summary, work_experiences, projects,
|
| 1063 |
+
educations, skills, achievements, section_order, 'standard'
|
| 1064 |
+
)
|
| 1065 |
+
elif format_type == 'pdf-modern':
|
| 1066 |
+
return generate_pdf_resume(
|
| 1067 |
+
intro, summary, work_experiences, projects,
|
| 1068 |
+
educations, skills, achievements, section_order, 'modern'
|
| 1069 |
+
)
|
| 1070 |
+
else:
|
| 1071 |
+
flash('Invalid resume format.', 'error')
|
| 1072 |
+
return redirect(url_for('profile'))
|
| 1073 |
+
|
| 1074 |
+
except Exception as e:
|
| 1075 |
+
import traceback
|
| 1076 |
+
app.logger.error(f"Error generating resume: {str(e)}")
|
| 1077 |
+
app.logger.error(traceback.format_exc())
|
| 1078 |
+
flash(f'An error occurred while generating your resume: {str(e)}', 'error')
|
| 1079 |
+
return redirect(url_for('profile'))
|
| 1080 |
+
|
| 1081 |
+
def generate_word_resume(intro, summary, work_experiences, projects, educations, skills, achievements, section_order):
|
| 1082 |
+
"""Generate Word document resume"""
|
| 1083 |
+
try:
|
| 1084 |
+
from docx import Document
|
| 1085 |
+
from docx.shared import Pt, Inches, RGBColor
|
| 1086 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 1087 |
+
|
| 1088 |
+
# Create document
|
| 1089 |
+
doc = Document()
|
| 1090 |
+
|
| 1091 |
+
# Set default font
|
| 1092 |
+
style = doc.styles['Normal']
|
| 1093 |
+
font = style.font
|
| 1094 |
+
font.name = 'Calibri'
|
| 1095 |
+
font.size = Pt(11)
|
| 1096 |
+
|
| 1097 |
+
# Header section
|
| 1098 |
+
name_para = doc.add_paragraph()
|
| 1099 |
+
name_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 1100 |
+
name_run = name_para.add_run(intro.name.upper())
|
| 1101 |
+
name_run.bold = True
|
| 1102 |
+
name_run.size = Pt(16)
|
| 1103 |
+
|
| 1104 |
+
# Contact info
|
| 1105 |
+
contact_para = doc.add_paragraph()
|
| 1106 |
+
contact_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 1107 |
+
contact_para.add_run(f"Email: {intro.email} | Phone: {intro.phone}")
|
| 1108 |
+
if intro.linkedin:
|
| 1109 |
+
contact_para.add_run(" | LinkedIn")
|
| 1110 |
+
if intro.github:
|
| 1111 |
+
contact_para.add_run(" | GitHub")
|
| 1112 |
+
if intro.website:
|
| 1113 |
+
contact_para.add_run(" | Website")
|
| 1114 |
+
|
| 1115 |
+
doc.add_paragraph()
|
| 1116 |
+
|
| 1117 |
+
# Profile summary
|
| 1118 |
+
if summary:
|
| 1119 |
+
doc.add_heading('Professional Summary', level=1)
|
| 1120 |
+
summary_para = doc.add_paragraph(summary.summary)
|
| 1121 |
+
if summary.ai_generated:
|
| 1122 |
+
ai_para = doc.add_paragraph("AI Generated")
|
| 1123 |
+
ai_para.italic = True
|
| 1124 |
+
ai_para.runs[0].font.size = Pt(9)
|
| 1125 |
+
doc.add_paragraph()
|
| 1126 |
+
|
| 1127 |
+
for section_name in section_order:
|
| 1128 |
+
if section_name == 'work_experience' and work_experiences:
|
| 1129 |
+
doc.add_heading('Work Experience', level=1)
|
| 1130 |
+
for exp in work_experiences:
|
| 1131 |
+
# Title and organization
|
| 1132 |
+
exp_para = doc.add_paragraph()
|
| 1133 |
+
exp_para.add_run(exp.title).bold = True
|
| 1134 |
+
exp_para.add_run(f" at {exp.organization}").italic = True
|
| 1135 |
+
|
| 1136 |
+
# Date
|
| 1137 |
+
date_para = doc.add_paragraph()
|
| 1138 |
+
date_text = f"{exp.start_month}/{exp.start_year} - "
|
| 1139 |
+
if exp.end_year:
|
| 1140 |
+
date_text += f"{exp.end_month}/{exp.end_year}"
|
| 1141 |
+
else:
|
| 1142 |
+
date_text += "Present"
|
| 1143 |
+
date_para.add_run(date_text)
|
| 1144 |
+
|
| 1145 |
+
# Remarks
|
| 1146 |
+
if exp.remarks:
|
| 1147 |
+
remarks_para = doc.add_paragraph(exp.remarks)
|
| 1148 |
+
|
| 1149 |
+
doc.add_paragraph()
|
| 1150 |
+
|
| 1151 |
+
elif section_name == 'projects' and projects:
|
| 1152 |
+
doc.add_heading('Projects', level=1)
|
| 1153 |
+
for project in projects:
|
| 1154 |
+
# Title and organization
|
| 1155 |
+
proj_para = doc.add_paragraph()
|
| 1156 |
+
proj_para.add_run(project.title).bold = True
|
| 1157 |
+
if project.organization:
|
| 1158 |
+
proj_para.add_run(f" at {project.organization}").italic = True
|
| 1159 |
+
|
| 1160 |
+
# Date
|
| 1161 |
+
date_para = doc.add_paragraph()
|
| 1162 |
+
date_text = f"{project.start_month}/{project.start_year} - "
|
| 1163 |
+
if project.end_year:
|
| 1164 |
+
date_text += f"{project.end_month}/{project.end_year}"
|
| 1165 |
+
else:
|
| 1166 |
+
date_text += "Present"
|
| 1167 |
+
date_para.add_run(date_text)
|
| 1168 |
+
|
| 1169 |
+
# Remarks
|
| 1170 |
+
if project.remarks:
|
| 1171 |
+
remarks_para = doc.add_paragraph(project.remarks)
|
| 1172 |
+
|
| 1173 |
+
doc.add_paragraph()
|
| 1174 |
+
|
| 1175 |
+
elif section_name == 'education' and educations:
|
| 1176 |
+
doc.add_heading('Education', level=1)
|
| 1177 |
+
for edu in educations:
|
| 1178 |
+
# Title and organization
|
| 1179 |
+
edu_para = doc.add_paragraph()
|
| 1180 |
+
edu_para.add_run(edu.title).bold = True
|
| 1181 |
+
edu_para.add_run(f" at {edu.organization}").italic = True
|
| 1182 |
+
|
| 1183 |
+
# Date
|
| 1184 |
+
date_para = doc.add_paragraph()
|
| 1185 |
+
date_text = f"{edu.start_month}/{edu.start_year} - "
|
| 1186 |
+
if edu.end_year:
|
| 1187 |
+
date_text += f"{edu.end_month}/{edu.end_year}"
|
| 1188 |
+
else:
|
| 1189 |
+
date_text += "Present"
|
| 1190 |
+
date_para.add_run(date_text)
|
| 1191 |
+
|
| 1192 |
+
# Remarks
|
| 1193 |
+
if edu.remarks:
|
| 1194 |
+
remarks_para = doc.add_paragraph(edu.remarks)
|
| 1195 |
+
|
| 1196 |
+
doc.add_paragraph()
|
| 1197 |
+
|
| 1198 |
+
elif section_name == 'skills' and skills:
|
| 1199 |
+
doc.add_heading('Skills', level=1)
|
| 1200 |
+
skills_text = ", ".join([skill.skill for skill in skills])
|
| 1201 |
+
doc.add_paragraph(skills_text)
|
| 1202 |
+
doc.add_paragraph()
|
| 1203 |
+
|
| 1204 |
+
elif section_name == 'achievements' and achievements:
|
| 1205 |
+
doc.add_heading('Achievements', level=1)
|
| 1206 |
+
for achievement in achievements:
|
| 1207 |
+
doc.add_paragraph(f"• {achievement.achievement}")
|
| 1208 |
+
doc.add_paragraph()
|
| 1209 |
+
|
| 1210 |
+
# Save to bytes
|
| 1211 |
+
from io import BytesIO
|
| 1212 |
+
doc_buffer = BytesIO()
|
| 1213 |
+
doc.save(doc_buffer)
|
| 1214 |
+
doc_buffer.seek(0)
|
| 1215 |
+
|
| 1216 |
+
# Return as downloadable file
|
| 1217 |
+
username = intro.name.replace(' ', '_')
|
| 1218 |
+
return send_file(
|
| 1219 |
+
doc_buffer,
|
| 1220 |
+
mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
| 1221 |
+
as_attachment=True,
|
| 1222 |
+
download_name=f'{username}_resume.docx'
|
| 1223 |
+
)
|
| 1224 |
+
|
| 1225 |
+
except Exception as e:
|
| 1226 |
+
app.logger.error(f"Error generating Word resume: {str(e)}")
|
| 1227 |
+
raise
|
| 1228 |
+
|
| 1229 |
+
def generate_pdf_resume(intro, summary, work_experiences, projects, educations, skills, achievements, section_order, template_type):
|
| 1230 |
+
"""Generate PDF resume using ReportLab"""
|
| 1231 |
+
try:
|
| 1232 |
+
from pdf_generator import create_pdf_resume
|
| 1233 |
+
from io import BytesIO
|
| 1234 |
+
import calendar
|
| 1235 |
+
|
| 1236 |
+
def format_date(month, year):
|
| 1237 |
+
"""Format month and year as 'Month Year'"""
|
| 1238 |
+
if month and year:
|
| 1239 |
+
try:
|
| 1240 |
+
month_name = calendar.month_name[int(month)]
|
| 1241 |
+
return f"{month_name[:3]} {year}"
|
| 1242 |
+
except:
|
| 1243 |
+
return f"{month}/{year}"
|
| 1244 |
+
return ""
|
| 1245 |
+
|
| 1246 |
+
# Prepare data for PDF generation
|
| 1247 |
+
work_exp_list = []
|
| 1248 |
+
if work_experiences:
|
| 1249 |
+
for exp in work_experiences:
|
| 1250 |
+
start_date = format_date(exp.start_month, exp.start_year)
|
| 1251 |
+
end_date = "Present" if not exp.end_month or not exp.end_year else format_date(exp.end_month, exp.end_year)
|
| 1252 |
+
|
| 1253 |
+
work_exp_list.append({
|
| 1254 |
+
'title': exp.title,
|
| 1255 |
+
'organization': exp.organization,
|
| 1256 |
+
'start_date': start_date,
|
| 1257 |
+
'end_date': end_date,
|
| 1258 |
+
'remarks': exp.remarks or ''
|
| 1259 |
+
})
|
| 1260 |
+
|
| 1261 |
+
projects_list = []
|
| 1262 |
+
if projects:
|
| 1263 |
+
for proj in projects:
|
| 1264 |
+
start_date = format_date(proj.start_month, proj.start_year)
|
| 1265 |
+
end_date = "Present" if not proj.end_month or not proj.end_year else format_date(proj.end_month, proj.end_year)
|
| 1266 |
+
|
| 1267 |
+
projects_list.append({
|
| 1268 |
+
'title': proj.title,
|
| 1269 |
+
'organization': proj.organization,
|
| 1270 |
+
'start_date': start_date,
|
| 1271 |
+
'end_date': end_date,
|
| 1272 |
+
'remarks': proj.remarks or ''
|
| 1273 |
+
})
|
| 1274 |
+
|
| 1275 |
+
education_list = []
|
| 1276 |
+
if educations:
|
| 1277 |
+
for edu in educations:
|
| 1278 |
+
start_date = format_date(edu.start_month, edu.start_year)
|
| 1279 |
+
end_date = "Present" if not edu.end_month or not edu.end_year else format_date(edu.end_month, edu.end_year)
|
| 1280 |
+
|
| 1281 |
+
education_list.append({
|
| 1282 |
+
'title': edu.title,
|
| 1283 |
+
'organization': edu.organization,
|
| 1284 |
+
'start_date': start_date,
|
| 1285 |
+
'end_date': end_date,
|
| 1286 |
+
'remarks': edu.remarks or ''
|
| 1287 |
+
})
|
| 1288 |
+
|
| 1289 |
+
# Convert skills and achievements to comma-separated strings
|
| 1290 |
+
skills_text = ', '.join([skill.skill for skill in skills]) if skills else ''
|
| 1291 |
+
achievements_text = ', '.join([achievement.achievement for achievement in achievements]) if achievements else ''
|
| 1292 |
+
|
| 1293 |
+
# Create data dictionary
|
| 1294 |
+
data = {
|
| 1295 |
+
'name': intro.name,
|
| 1296 |
+
'email': intro.email,
|
| 1297 |
+
'phone': intro.phone,
|
| 1298 |
+
'linkedin': intro.linkedin,
|
| 1299 |
+
'github': intro.github,
|
| 1300 |
+
'website': intro.website,
|
| 1301 |
+
'summary': summary.summary if summary else '',
|
| 1302 |
+
'work_experience': work_exp_list,
|
| 1303 |
+
'projects': projects_list,
|
| 1304 |
+
'education': education_list,
|
| 1305 |
+
'skills': skills_text,
|
| 1306 |
+
'achievements': achievements_text,
|
| 1307 |
+
'sections_order': section_order
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
# Generate PDF
|
| 1311 |
+
pdf_bytes = create_pdf_resume(data, template_type)
|
| 1312 |
+
|
| 1313 |
+
if pdf_bytes:
|
| 1314 |
+
# Return as downloadable file
|
| 1315 |
+
username = intro.name.replace(' ', '_')
|
| 1316 |
+
filename = f'{username}_resume.pdf' if template_type == 'standard' else f'{username}_resume_modern.pdf'
|
| 1317 |
+
|
| 1318 |
+
pdf_buffer = BytesIO(pdf_bytes)
|
| 1319 |
+
|
| 1320 |
+
return send_file(
|
| 1321 |
+
pdf_buffer,
|
| 1322 |
+
mimetype='application/pdf',
|
| 1323 |
+
as_attachment=True,
|
| 1324 |
+
download_name=filename
|
| 1325 |
+
)
|
| 1326 |
+
else:
|
| 1327 |
+
raise Exception("PDF generation failed")
|
| 1328 |
+
|
| 1329 |
+
except Exception as e:
|
| 1330 |
+
app.logger.error(f"Error generating PDF resume: {str(e)}")
|
| 1331 |
+
raise
|
| 1332 |
+
|
| 1333 |
+
# Admin Panel Route
|
| 1334 |
+
@app.route('/admin')
|
| 1335 |
+
@admin_required
|
| 1336 |
+
def admin_panel():
|
| 1337 |
+
"""Admin panel to manage users"""
|
| 1338 |
+
users = User.query.order_by(User.created_at.desc()).all()
|
| 1339 |
+
return render_template('admin.html', users=users)
|
| 1340 |
+
|
| 1341 |
+
# Admin API Endpoints
|
| 1342 |
+
@app.route('/api/admin/users', methods=['GET'])
|
| 1343 |
+
@admin_required
|
| 1344 |
+
def api_get_users():
|
| 1345 |
+
"""Get list of all users"""
|
| 1346 |
+
try:
|
| 1347 |
+
users = User.query.order_by(User.created_at.desc()).all()
|
| 1348 |
+
users_data = []
|
| 1349 |
+
|
| 1350 |
+
for user in users:
|
| 1351 |
+
users_data.append({
|
| 1352 |
+
'id': str(user.id),
|
| 1353 |
+
'email': user.email,
|
| 1354 |
+
'name': user.name,
|
| 1355 |
+
'created_at': user.created_at.isoformat() if user.created_at else None,
|
| 1356 |
+
'is_admin': user.is_admin,
|
| 1357 |
+
'role': user.role
|
| 1358 |
+
})
|
| 1359 |
+
|
| 1360 |
+
return jsonify({
|
| 1361 |
+
'success': True,
|
| 1362 |
+
'users': users_data
|
| 1363 |
+
})
|
| 1364 |
+
except Exception as e:
|
| 1365 |
+
app.logger.error(f"Error fetching users: {str(e)}")
|
| 1366 |
+
return jsonify({
|
| 1367 |
+
'success': False,
|
| 1368 |
+
'error': str(e)
|
| 1369 |
+
}), 500
|
| 1370 |
+
|
| 1371 |
+
@app.route('/api/admin/users/<user_id>', methods=['DELETE'])
|
| 1372 |
+
@admin_required
|
| 1373 |
+
def api_delete_user(user_id):
|
| 1374 |
+
"""Delete a user and all their profile data"""
|
| 1375 |
+
try:
|
| 1376 |
+
# Find the user and store email before deletion
|
| 1377 |
+
user = User.query.get(user_id)
|
| 1378 |
+
if not user:
|
| 1379 |
+
return jsonify({
|
| 1380 |
+
'success': False,
|
| 1381 |
+
'error': 'User not found'
|
| 1382 |
+
}), 404
|
| 1383 |
+
|
| 1384 |
+
# Store user email for success message
|
| 1385 |
+
user_email = user.email
|
| 1386 |
+
|
| 1387 |
+
# Don't allow deleting admin users
|
| 1388 |
+
if user.is_admin:
|
| 1389 |
+
return jsonify({
|
| 1390 |
+
'success': False,
|
| 1391 |
+
'error': 'Cannot delete admin users'
|
| 1392 |
+
}), 400
|
| 1393 |
+
|
| 1394 |
+
# Don't allow deleting self
|
| 1395 |
+
if str(user.id) == str(current_user.id):
|
| 1396 |
+
return jsonify({
|
| 1397 |
+
'success': False,
|
| 1398 |
+
'error': 'Cannot delete your own account'
|
| 1399 |
+
}), 400
|
| 1400 |
+
|
| 1401 |
+
# Use direct SQL to delete all profile data first
|
| 1402 |
+
# This ensures we handle any duplicate data that might exist
|
| 1403 |
+
|
| 1404 |
+
# Delete from all profile tables
|
| 1405 |
+
from sqlalchemy import text
|
| 1406 |
+
|
| 1407 |
+
# Delete introductions (handle potential duplicates)
|
| 1408 |
+
db.session.execute(text("DELETE FROM introductions WHERE user_id = :user_id"), {'user_id': str(user_id)})
|
| 1409 |
+
|
| 1410 |
+
# Delete profile summaries
|
| 1411 |
+
db.session.execute(text("DELETE FROM profile_summaries WHERE user_id = :user_id"), {'user_id': str(user_id)})
|
| 1412 |
+
|
| 1413 |
+
# Delete work experiences
|
| 1414 |
+
db.session.execute(text("DELETE FROM work_experiences WHERE user_id = :user_id"), {'user_id': str(user_id)})
|
| 1415 |
+
|
| 1416 |
+
# Delete projects
|
| 1417 |
+
db.session.execute(text("DELETE FROM projects WHERE user_id = :user_id"), {'user_id': str(user_id)})
|
| 1418 |
+
|
| 1419 |
+
# Delete education
|
| 1420 |
+
db.session.execute(text("DELETE FROM educations WHERE user_id = :user_id"), {'user_id': str(user_id)})
|
| 1421 |
+
|
| 1422 |
+
# Delete skills
|
| 1423 |
+
db.session.execute(text("DELETE FROM skills WHERE user_id = :user_id"), {'user_id': str(user_id)})
|
| 1424 |
+
|
| 1425 |
+
# Delete achievements
|
| 1426 |
+
db.session.execute(text("DELETE FROM achievements WHERE user_id = :user_id"), {'user_id': str(user_id)})
|
| 1427 |
+
|
| 1428 |
+
# Delete section order
|
| 1429 |
+
db.session.execute(text("DELETE FROM profile_section_orders WHERE user_id = :user_id"), {'user_id': str(user_id)})
|
| 1430 |
+
|
| 1431 |
+
# Finally delete the user
|
| 1432 |
+
db.session.execute(text("DELETE FROM users WHERE id = :user_id"), {'user_id': str(user_id)})
|
| 1433 |
+
|
| 1434 |
+
# Expunge the user object from session to avoid the deleted instance warning
|
| 1435 |
+
db.session.expunge(user)
|
| 1436 |
+
|
| 1437 |
+
db.session.commit()
|
| 1438 |
+
|
| 1439 |
+
return jsonify({
|
| 1440 |
+
'success': True,
|
| 1441 |
+
'message': f'User {user_email} and all their profile data have been deleted successfully'
|
| 1442 |
+
})
|
| 1443 |
+
|
| 1444 |
+
except Exception as e:
|
| 1445 |
+
db.session.rollback()
|
| 1446 |
+
app.logger.error(f"Error deleting user: {str(e)}")
|
| 1447 |
+
return jsonify({
|
| 1448 |
+
'success': False,
|
| 1449 |
+
'error': str(e)
|
| 1450 |
+
}), 500
|
| 1451 |
+
|
| 1452 |
+
def create_tables_if_needed():
|
| 1453 |
+
"""Create database tables if they don't exist"""
|
| 1454 |
+
with app.app_context():
|
| 1455 |
+
# Check if tables exist
|
| 1456 |
+
inspector = db.inspect(db.engine)
|
| 1457 |
+
table_names = inspector.get_table_names()
|
| 1458 |
+
|
| 1459 |
+
if not table_names or 'users' not in table_names:
|
| 1460 |
+
print("Creating database tables...")
|
| 1461 |
+
db.create_all()
|
| 1462 |
+
print("Database tables created successfully!")
|
| 1463 |
+
|
| 1464 |
+
# Run admin migration if needed
|
| 1465 |
+
try:
|
| 1466 |
+
from sqlalchemy import text
|
| 1467 |
+
columns = [column['name'] for column in inspector.get_columns('users')]
|
| 1468 |
+
if 'is_admin' not in columns:
|
| 1469 |
+
print("Running admin migration...")
|
| 1470 |
+
db.session.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE'))
|
| 1471 |
+
db.session.execute(text('ALTER TABLE users ADD COLUMN role VARCHAR(50) DEFAULT \'User\''))
|
| 1472 |
+
db.session.commit()
|
| 1473 |
+
print("Admin migration completed!")
|
| 1474 |
+
except Exception as e:
|
| 1475 |
+
print(f"Migration error: {e}")
|
| 1476 |
+
|
| 1477 |
+
# Initialize database
|
| 1478 |
+
create_tables_if_needed()
|
| 1479 |
+
|
| 1480 |
+
if __name__ == "__main__":
|
| 1481 |
+
port = int(os.environ.get('PORT', 5000))
|
| 1482 |
+
# Railway requires binding to 0.0.0.0
|
| 1483 |
+
# Check if we're running on Railway
|
| 1484 |
+
is_railway = os.environ.get('RAILWAY_ENVIRONMENT') or os.environ.get('RAILWAY_ENVIRONMENT_NAME')
|
| 1485 |
+
host = '0.0.0.0' if is_railway else '127.0.0.1'
|
| 1486 |
+
print(f"Flask running at http://{host}:{port}")
|
| 1487 |
+
# Disable debug mode in production
|
| 1488 |
+
debug = not is_railway
|
| 1489 |
+
app.run(host=host, port=port, debug=debug)
|
check_profiles.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Check all users and their profiles.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from models import db, User, Introduction, WorkExperience, Project, Education, Skill, Achievement
|
| 7 |
+
from app import app
|
| 8 |
+
|
| 9 |
+
def check_all_profiles():
|
| 10 |
+
"""Check all users and their profile data."""
|
| 11 |
+
|
| 12 |
+
with app.app_context():
|
| 13 |
+
users = User.query.all()
|
| 14 |
+
print(f"Total users: {len(users)}")
|
| 15 |
+
|
| 16 |
+
for user in users:
|
| 17 |
+
print(f"\nUser: {user.email} (ID: {user.id})")
|
| 18 |
+
|
| 19 |
+
# Check introduction
|
| 20 |
+
intro = Introduction.query.filter_by(user_id=user.id).first()
|
| 21 |
+
print(f" Has introduction: {'Yes' if intro else 'No'}")
|
| 22 |
+
if intro:
|
| 23 |
+
print(f" Name: {intro.name}")
|
| 24 |
+
|
| 25 |
+
# Check profile summary
|
| 26 |
+
from models import ProfileSummary
|
| 27 |
+
summary = ProfileSummary.query.filter_by(user_id=user.id).first()
|
| 28 |
+
print(f" Has profile summary: {'Yes' if summary else 'No'}")
|
| 29 |
+
|
| 30 |
+
# Check other sections
|
| 31 |
+
work_count = WorkExperience.query.filter_by(user_id=user.id).count()
|
| 32 |
+
project_count = Project.query.filter_by(user_id=user.id).count()
|
| 33 |
+
education_count = Education.query.filter_by(user_id=user.id).count()
|
| 34 |
+
skill_count = Skill.query.filter_by(user_id=user.id).count()
|
| 35 |
+
achievement_count = Achievement.query.filter_by(user_id=user.id).count()
|
| 36 |
+
|
| 37 |
+
print(f" Work experiences: {work_count}")
|
| 38 |
+
print(f" Projects: {project_count}")
|
| 39 |
+
print(f" Education: {education_count}")
|
| 40 |
+
print(f" Skills: {skill_count}")
|
| 41 |
+
print(f" Achievements: {achievement_count}")
|
| 42 |
+
|
| 43 |
+
if __name__ == "__main__":
|
| 44 |
+
check_all_profiles()
|
cleanup_duplicates.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app import app, db, Introduction, ProfileSummary
|
| 2 |
+
from sqlalchemy import func
|
| 3 |
+
|
| 4 |
+
def cleanup_duplicate_records():
|
| 5 |
+
"""Clean up duplicate introduction and profile summary records"""
|
| 6 |
+
with app.app_context():
|
| 7 |
+
# Find duplicate introductions
|
| 8 |
+
dup_intros = db.session.query(
|
| 9 |
+
Introduction.user_id,
|
| 10 |
+
func.count(Introduction.id).label('count')
|
| 11 |
+
).group_by(Introduction.user_id).having(func.count(Introduction.id) > 1).all()
|
| 12 |
+
|
| 13 |
+
print(f"Found {len(dup_intros)} users with duplicate introductions")
|
| 14 |
+
|
| 15 |
+
# For each user with duplicates, keep the most recent one
|
| 16 |
+
for user_id, count in dup_intros:
|
| 17 |
+
# Get all introductions for this user, ordered by creation date
|
| 18 |
+
intros = Introduction.query.filter_by(user_id=user_id).order_by(Introduction.created_at.desc()).all()
|
| 19 |
+
|
| 20 |
+
# Keep the first (most recent), delete the rest
|
| 21 |
+
for intro in intros[1:]:
|
| 22 |
+
print(f"Deleting duplicate introduction for user {user_id}: {intro.id}")
|
| 23 |
+
db.session.delete(intro)
|
| 24 |
+
|
| 25 |
+
# Find duplicate profile summaries
|
| 26 |
+
dup_summaries = db.session.query(
|
| 27 |
+
ProfileSummary.user_id,
|
| 28 |
+
func.count(ProfileSummary.id).label('count')
|
| 29 |
+
).group_by(ProfileSummary.user_id).having(func.count(ProfileSummary.id) > 1).all()
|
| 30 |
+
|
| 31 |
+
print(f"Found {len(dup_summaries)} users with duplicate profile summaries")
|
| 32 |
+
|
| 33 |
+
# For each user with duplicates, keep the most recent one
|
| 34 |
+
for user_id, count in dup_summaries:
|
| 35 |
+
# Get all profile summaries for this user, ordered by creation date
|
| 36 |
+
summaries = ProfileSummary.query.filter_by(user_id=user_id).order_by(ProfileSummary.created_at.desc()).all()
|
| 37 |
+
|
| 38 |
+
# Keep the first (most recent), delete the rest
|
| 39 |
+
for summary in summaries[1:]:
|
| 40 |
+
print(f"Deleting duplicate profile summary for user {user_id}: {summary.id}")
|
| 41 |
+
db.session.delete(summary)
|
| 42 |
+
|
| 43 |
+
# Commit the changes
|
| 44 |
+
db.session.commit()
|
| 45 |
+
print("Cleanup completed!")
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
cleanup_duplicate_records()
|
create_tables.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database setup script to create tables for the auth system
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
from flask import Flask
|
| 7 |
+
from models import db, User
|
| 8 |
+
|
| 9 |
+
def create_app():
|
| 10 |
+
"""Create Flask app for database operations"""
|
| 11 |
+
app = Flask(__name__)
|
| 12 |
+
|
| 13 |
+
# Configure Flask
|
| 14 |
+
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY')
|
| 15 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI')
|
| 16 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 17 |
+
|
| 18 |
+
# Initialize database
|
| 19 |
+
db.init_app(app)
|
| 20 |
+
|
| 21 |
+
return app
|
| 22 |
+
|
| 23 |
+
def create_tables():
|
| 24 |
+
"""Create database tables"""
|
| 25 |
+
print("Creating database tables...")
|
| 26 |
+
|
| 27 |
+
app = create_app()
|
| 28 |
+
|
| 29 |
+
with app.app_context():
|
| 30 |
+
try:
|
| 31 |
+
# Create all tables
|
| 32 |
+
db.create_all()
|
| 33 |
+
print("Database tables created successfully!")
|
| 34 |
+
|
| 35 |
+
# Check if tables exist
|
| 36 |
+
inspector = db.inspect(db.engine)
|
| 37 |
+
tables = inspector.get_table_names()
|
| 38 |
+
print(f"Tables created: {tables}")
|
| 39 |
+
|
| 40 |
+
except Exception as e:
|
| 41 |
+
print(f"Error creating tables: {e}")
|
| 42 |
+
raise
|
| 43 |
+
|
| 44 |
+
if __name__ == "__main__":
|
| 45 |
+
# Load environment variables
|
| 46 |
+
load_dotenv()
|
| 47 |
+
|
| 48 |
+
# Verify database URL
|
| 49 |
+
db_url = os.environ.get('SQLALCHEMY_DATABASE_URI')
|
| 50 |
+
if not db_url:
|
| 51 |
+
print("DATABASE_URL not found in environment variables")
|
| 52 |
+
exit(1)
|
| 53 |
+
|
| 54 |
+
print(f"Using database: {db_url.split('@')[1] if '@' in db_url else 'local'}")
|
| 55 |
+
|
| 56 |
+
# Create tables
|
| 57 |
+
create_tables()
|
debug_profile.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Debug script to check database state and test PDF generation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from models import db, User, Introduction, WorkExperience, Project, Education, Skill, Achievement, ProfileSectionOrder
|
| 7 |
+
from app import app
|
| 8 |
+
import calendar
|
| 9 |
+
|
| 10 |
+
def debug_user_profile():
|
| 11 |
+
"""Debug user profile data."""
|
| 12 |
+
|
| 13 |
+
with app.app_context():
|
| 14 |
+
# Get first user (for testing)
|
| 15 |
+
user = User.query.first()
|
| 16 |
+
if not user:
|
| 17 |
+
print("No users found in database")
|
| 18 |
+
return
|
| 19 |
+
|
| 20 |
+
print(f"Debugging profile for user: {user.email}")
|
| 21 |
+
|
| 22 |
+
# Get profile data
|
| 23 |
+
intro = Introduction.query.filter_by(user_id=user.id).first()
|
| 24 |
+
if not intro:
|
| 25 |
+
print("No introduction found")
|
| 26 |
+
return
|
| 27 |
+
|
| 28 |
+
print(f"Introduction: {intro.name}")
|
| 29 |
+
|
| 30 |
+
# Get work experiences
|
| 31 |
+
work_experiences = WorkExperience.query.filter_by(user_id=user.id).all()
|
| 32 |
+
print(f"Work experiences: {len(work_experiences)}")
|
| 33 |
+
for exp in work_experiences:
|
| 34 |
+
print(f" - {exp.title} at {exp.organization} ({exp.start_month}/{exp.start_year} - {exp.end_month or 'Present'}/{exp.end_year or ''})")
|
| 35 |
+
|
| 36 |
+
# Get projects
|
| 37 |
+
projects = Project.query.filter_by(user_id=user.id).all()
|
| 38 |
+
print(f"Projects: {len(projects)}")
|
| 39 |
+
|
| 40 |
+
# Get education
|
| 41 |
+
educations = Education.query.filter_by(user_id=user.id).all()
|
| 42 |
+
print(f"Education: {len(educations)}")
|
| 43 |
+
|
| 44 |
+
# Get skills
|
| 45 |
+
skills = Skill.query.filter_by(user_id=user.id).all()
|
| 46 |
+
print(f"Skills: {len(skills)}")
|
| 47 |
+
for skill in skills:
|
| 48 |
+
print(f" - {skill.skill}")
|
| 49 |
+
|
| 50 |
+
# Get achievements
|
| 51 |
+
achievements = Achievement.query.filter_by(user_id=user.id).all()
|
| 52 |
+
print(f"Achievements: {len(achievements)}")
|
| 53 |
+
for achievement in achievements:
|
| 54 |
+
print(f" - {achievement.achievement}")
|
| 55 |
+
|
| 56 |
+
# Test data preparation (same as in app.py)
|
| 57 |
+
def format_date(month, year):
|
| 58 |
+
"""Format month and year as 'Month Year'"""
|
| 59 |
+
if month and year:
|
| 60 |
+
try:
|
| 61 |
+
month_name = calendar.month_name[int(month)]
|
| 62 |
+
return f"{month_name[:3]} {year}"
|
| 63 |
+
except:
|
| 64 |
+
return f"{month}/{year}"
|
| 65 |
+
return ""
|
| 66 |
+
|
| 67 |
+
# Prepare work experiences
|
| 68 |
+
work_exp_list = []
|
| 69 |
+
for exp in work_experiences:
|
| 70 |
+
start_date = format_date(exp.start_month, exp.start_year)
|
| 71 |
+
end_date = "Present" if not exp.end_month or not exp.end_year else format_date(exp.end_month, exp.end_year)
|
| 72 |
+
|
| 73 |
+
work_exp_list.append({
|
| 74 |
+
'title': exp.title,
|
| 75 |
+
'organization': exp.organization,
|
| 76 |
+
'start_date': start_date,
|
| 77 |
+
'end_date': end_date,
|
| 78 |
+
'remarks': exp.remarks or ''
|
| 79 |
+
})
|
| 80 |
+
print(f"Formatted work exp: {work_exp_list[-1]}")
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
debug_user_profile()
|
debug_user_delete.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Debug script to check user and their relationships
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
# Load environment variables
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
# Add current directory to Python path
|
| 13 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 14 |
+
|
| 15 |
+
from flask import Flask
|
| 16 |
+
from models import db, User, Introduction, ProfileSummary, WorkExperience, Project, Education, Skill, Achievement, ProfileSectionOrder
|
| 17 |
+
|
| 18 |
+
def debug_user_relationships(user_email):
|
| 19 |
+
"""Debug user relationships"""
|
| 20 |
+
app = Flask(__name__)
|
| 21 |
+
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
| 22 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI')
|
| 23 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 24 |
+
|
| 25 |
+
db.init_app(app)
|
| 26 |
+
|
| 27 |
+
with app.app_context():
|
| 28 |
+
user = User.query.filter_by(email=user_email).first()
|
| 29 |
+
if not user:
|
| 30 |
+
print(f"User {user_email} not found")
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
print(f"User: {user.email} (ID: {user.id})")
|
| 34 |
+
print(f"Is admin: {user.is_admin}")
|
| 35 |
+
|
| 36 |
+
# Check relationships
|
| 37 |
+
print("\nProfile data:")
|
| 38 |
+
print(f"Introduction: {user.introduction}")
|
| 39 |
+
print(f"Profile Summary: {user.profile_summary}")
|
| 40 |
+
print(f"Work experiences: {len(user.work_experiences)}")
|
| 41 |
+
print(f"Projects: {len(user.projects)}")
|
| 42 |
+
print(f"Education: {len(user.educations)}")
|
| 43 |
+
print(f"Skills: {len(user.skills)}")
|
| 44 |
+
print(f"Achievements: {len(user.achievements)}")
|
| 45 |
+
print(f"Section order: {user.section_order}")
|
| 46 |
+
|
| 47 |
+
# Check direct queries
|
| 48 |
+
print("\nDirect database queries:")
|
| 49 |
+
intro = Introduction.query.filter_by(user_id=user.id).first()
|
| 50 |
+
print(f"Direct intro query: {intro}")
|
| 51 |
+
|
| 52 |
+
# Check if user has any introductions
|
| 53 |
+
from sqlalchemy import text
|
| 54 |
+
result = db.session.execute(text("SELECT COUNT(*) FROM introductions WHERE user_id = :user_id"), {'user_id': str(user.id)})
|
| 55 |
+
count = result.scalar()
|
| 56 |
+
print(f"Introduction count from direct SQL: {count}")
|
| 57 |
+
|
| 58 |
+
if __name__ == '__main__':
|
| 59 |
+
debug_user_relationships('test1@example.com')
|
migrate_admin_columns.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Migration script to add admin columns to users table
|
| 4 |
+
Run this script to update the database schema for admin functionality
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
# Load environment variables
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
# Add current directory to Python path
|
| 15 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 16 |
+
|
| 17 |
+
from flask import Flask
|
| 18 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 19 |
+
from models import db, User
|
| 20 |
+
import uuid
|
| 21 |
+
from sqlalchemy import text
|
| 22 |
+
|
| 23 |
+
def migrate_database():
|
| 24 |
+
"""Add is_admin and role columns to users table"""
|
| 25 |
+
|
| 26 |
+
app = Flask(__name__)
|
| 27 |
+
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
| 28 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('SQLALCHEMY_DATABASE_URI')
|
| 29 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 30 |
+
|
| 31 |
+
db.init_app(app)
|
| 32 |
+
|
| 33 |
+
with app.app_context():
|
| 34 |
+
# Check if columns already exist
|
| 35 |
+
inspector = db.inspect(db.engine)
|
| 36 |
+
columns = [column['name'] for column in inspector.get_columns('users')]
|
| 37 |
+
|
| 38 |
+
if 'is_admin' not in columns:
|
| 39 |
+
print("Adding is_admin column...")
|
| 40 |
+
db.session.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE'))
|
| 41 |
+
|
| 42 |
+
if 'role' not in columns:
|
| 43 |
+
print("Adding role column...")
|
| 44 |
+
db.session.execute(text('ALTER TABLE users ADD COLUMN role VARCHAR(50) DEFAULT \'User\''))
|
| 45 |
+
|
| 46 |
+
db.session.commit()
|
| 47 |
+
print("Migration completed successfully!")
|
| 48 |
+
|
| 49 |
+
# Update admin user if exists
|
| 50 |
+
admin_email = os.environ.get('ADMIN_EMAIL')
|
| 51 |
+
if admin_email:
|
| 52 |
+
admin_user = User.query.filter_by(email=admin_email).first()
|
| 53 |
+
if admin_user:
|
| 54 |
+
admin_user.is_admin = True
|
| 55 |
+
admin_user.role = 'Admin'
|
| 56 |
+
db.session.commit()
|
| 57 |
+
print(f"Updated {admin_email} as admin user")
|
| 58 |
+
else:
|
| 59 |
+
print(f"Admin user {admin_email} not found in database")
|
| 60 |
+
|
| 61 |
+
if __name__ == '__main__':
|
| 62 |
+
migrate_database()
|
models.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 2 |
+
from flask_login import UserMixin
|
| 3 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 4 |
+
from sqlalchemy.orm import relationship
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 8 |
+
|
| 9 |
+
db = SQLAlchemy()
|
| 10 |
+
|
| 11 |
+
class User(UserMixin, db.Model):
|
| 12 |
+
__tablename__ = 'users'
|
| 13 |
+
|
| 14 |
+
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 15 |
+
email = db.Column(db.String(255), unique=True, nullable=False)
|
| 16 |
+
password_hash = db.Column(db.String(255), nullable=False)
|
| 17 |
+
name = db.Column(db.String(255), nullable=False)
|
| 18 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 19 |
+
is_admin = db.Column(db.Boolean, default=False, nullable=False)
|
| 20 |
+
role = db.Column(db.String(50), default='User', nullable=False)
|
| 21 |
+
|
| 22 |
+
# Profile sections
|
| 23 |
+
introduction = relationship("Introduction", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
| 24 |
+
profile_summary = relationship("ProfileSummary", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
| 25 |
+
work_experiences = relationship("WorkExperience", back_populates="user", cascade="all, delete-orphan")
|
| 26 |
+
projects = relationship("Project", back_populates="user", cascade="all, delete-orphan")
|
| 27 |
+
educations = relationship("Education", back_populates="user", cascade="all, delete-orphan")
|
| 28 |
+
skills = relationship("Skill", back_populates="user", cascade="all, delete-orphan")
|
| 29 |
+
achievements = relationship("Achievement", back_populates="user", cascade="all, delete-orphan")
|
| 30 |
+
section_order = relationship("ProfileSectionOrder", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
| 31 |
+
|
| 32 |
+
def set_password(self, password):
|
| 33 |
+
self.password_hash = generate_password_hash(password)
|
| 34 |
+
|
| 35 |
+
def check_password(self, password):
|
| 36 |
+
# Check if it's a bcrypt hash (starts with $2a$, $2b$, etc.)
|
| 37 |
+
if self.password_hash.startswith('$2'):
|
| 38 |
+
import bcrypt
|
| 39 |
+
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
|
| 40 |
+
else:
|
| 41 |
+
# Use Werkzeug's check_password_hash for other hash types
|
| 42 |
+
return check_password_hash(self.password_hash, password)
|
| 43 |
+
|
| 44 |
+
def get_id(self):
|
| 45 |
+
return str(self.id)
|
| 46 |
+
|
| 47 |
+
def __repr__(self):
|
| 48 |
+
return f'<User {self.email}>'
|
| 49 |
+
|
| 50 |
+
class Introduction(db.Model):
|
| 51 |
+
__tablename__ = 'introductions'
|
| 52 |
+
|
| 53 |
+
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 54 |
+
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False)
|
| 55 |
+
name = db.Column(db.String(255), nullable=False)
|
| 56 |
+
email = db.Column(db.String(255), nullable=False)
|
| 57 |
+
phone = db.Column(db.String(50), nullable=False)
|
| 58 |
+
linkedin = db.Column(db.String(255))
|
| 59 |
+
github = db.Column(db.String(255))
|
| 60 |
+
website = db.Column(db.String(255))
|
| 61 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 62 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 63 |
+
|
| 64 |
+
user = relationship("User", back_populates="introduction")
|
| 65 |
+
|
| 66 |
+
class ProfileSummary(db.Model):
|
| 67 |
+
__tablename__ = 'profile_summaries'
|
| 68 |
+
|
| 69 |
+
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 70 |
+
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False)
|
| 71 |
+
summary = db.Column(db.Text)
|
| 72 |
+
ai_generated = db.Column(db.Boolean, default=False)
|
| 73 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 74 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 75 |
+
|
| 76 |
+
user = relationship("User", back_populates="profile_summary")
|
| 77 |
+
|
| 78 |
+
class WorkExperience(db.Model):
|
| 79 |
+
__tablename__ = 'work_experiences'
|
| 80 |
+
|
| 81 |
+
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 82 |
+
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False)
|
| 83 |
+
organization = db.Column(db.String(255), nullable=False)
|
| 84 |
+
title = db.Column(db.String(255), nullable=False)
|
| 85 |
+
start_month = db.Column(db.Integer, nullable=False) # 1-12
|
| 86 |
+
start_year = db.Column(db.Integer, nullable=False)
|
| 87 |
+
end_month = db.Column(db.Integer) # NULL if present
|
| 88 |
+
end_year = db.Column(db.Integer) # NULL if present
|
| 89 |
+
remarks = db.Column(db.Text)
|
| 90 |
+
order = db.Column(db.Integer, default=0)
|
| 91 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 92 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 93 |
+
|
| 94 |
+
user = relationship("User", back_populates="work_experiences")
|
| 95 |
+
|
| 96 |
+
class Project(db.Model):
|
| 97 |
+
__tablename__ = 'projects'
|
| 98 |
+
|
| 99 |
+
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 100 |
+
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False)
|
| 101 |
+
organization = db.Column(db.String(255))
|
| 102 |
+
title = db.Column(db.String(255), nullable=False)
|
| 103 |
+
start_month = db.Column(db.Integer, nullable=False) # 1-12
|
| 104 |
+
start_year = db.Column(db.Integer, nullable=False)
|
| 105 |
+
end_month = db.Column(db.Integer) # NULL if present
|
| 106 |
+
end_year = db.Column(db.Integer) # NULL if present
|
| 107 |
+
remarks = db.Column(db.Text)
|
| 108 |
+
order = db.Column(db.Integer, default=0)
|
| 109 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 110 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 111 |
+
|
| 112 |
+
user = relationship("User", back_populates="projects")
|
| 113 |
+
|
| 114 |
+
class Education(db.Model):
|
| 115 |
+
__tablename__ = 'educations'
|
| 116 |
+
|
| 117 |
+
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 118 |
+
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False)
|
| 119 |
+
organization = db.Column(db.String(255), nullable=False)
|
| 120 |
+
title = db.Column(db.String(255), nullable=False)
|
| 121 |
+
start_month = db.Column(db.Integer, nullable=False) # 1-12
|
| 122 |
+
start_year = db.Column(db.Integer, nullable=False)
|
| 123 |
+
end_month = db.Column(db.Integer) # NULL if present
|
| 124 |
+
end_year = db.Column(db.Integer) # NULL if present
|
| 125 |
+
remarks = db.Column(db.Text)
|
| 126 |
+
order = db.Column(db.Integer, default=0)
|
| 127 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 128 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 129 |
+
|
| 130 |
+
user = relationship("User", back_populates="educations")
|
| 131 |
+
|
| 132 |
+
class Skill(db.Model):
|
| 133 |
+
__tablename__ = 'skills'
|
| 134 |
+
|
| 135 |
+
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 136 |
+
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False)
|
| 137 |
+
skill = db.Column(db.String(255), nullable=False)
|
| 138 |
+
order = db.Column(db.Integer, default=0)
|
| 139 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 140 |
+
|
| 141 |
+
user = relationship("User", back_populates="skills")
|
| 142 |
+
|
| 143 |
+
class Achievement(db.Model):
|
| 144 |
+
__tablename__ = 'achievements'
|
| 145 |
+
|
| 146 |
+
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 147 |
+
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False)
|
| 148 |
+
achievement = db.Column(db.String(255), nullable=False)
|
| 149 |
+
order = db.Column(db.Integer, default=0)
|
| 150 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 151 |
+
|
| 152 |
+
user = relationship("User", back_populates="achievements")
|
| 153 |
+
|
| 154 |
+
class ProfileSectionOrder(db.Model):
|
| 155 |
+
__tablename__ = 'profile_section_orders'
|
| 156 |
+
|
| 157 |
+
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 158 |
+
user_id = db.Column(UUID(as_uuid=True), db.ForeignKey('users.id'), nullable=False, unique=True)
|
| 159 |
+
section_order = db.Column(db.JSON, nullable=False) # Stores order as JSON array
|
| 160 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 161 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 162 |
+
|
| 163 |
+
user = relationship("User", back_populates="section_order")
|
pdf_generator.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PDF generation using ReportLab.
|
| 3 |
+
This module provides functions to create PDF documents directly.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
from reportlab.lib.pagesizes import letter, A4
|
| 9 |
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 10 |
+
from reportlab.lib.units import inch, cm
|
| 11 |
+
from reportlab.lib.colors import black, grey
|
| 12 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
| 13 |
+
from reportlab.platypus.flowables import PageBreak
|
| 14 |
+
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
|
| 15 |
+
from utils import log_error
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def create_pdf_resume(data, template_type="standard"):
|
| 19 |
+
"""
|
| 20 |
+
Create a PDF resume using ReportLab.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
data (dict): Resume data
|
| 24 |
+
template_type (str): 'standard' or 'modern'
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
bytes: PDF bytes
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
buffer = BytesIO()
|
| 31 |
+
|
| 32 |
+
if template_type == "modern":
|
| 33 |
+
doc = SimpleDocTemplate(buffer, pagesize=A4,
|
| 34 |
+
leftMargin=0.5*inch, rightMargin=0.5*inch,
|
| 35 |
+
topMargin=0.5*inch, bottomMargin=0.5*inch)
|
| 36 |
+
elements = _create_modern_resume_elements(data)
|
| 37 |
+
else:
|
| 38 |
+
doc = SimpleDocTemplate(buffer, pagesize=A4,
|
| 39 |
+
leftMargin=0.75*inch, rightMargin=0.75*inch,
|
| 40 |
+
topMargin=0.75*inch, bottomMargin=0.75*inch)
|
| 41 |
+
elements = _create_standard_resume_elements(data)
|
| 42 |
+
|
| 43 |
+
doc.build(elements)
|
| 44 |
+
pdf_bytes = buffer.getvalue()
|
| 45 |
+
buffer.close()
|
| 46 |
+
|
| 47 |
+
return pdf_bytes
|
| 48 |
+
|
| 49 |
+
except Exception as e:
|
| 50 |
+
log_error(f"PDF creation failed: {str(e)}", e)
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _create_standard_resume_elements(data):
|
| 55 |
+
"""Create elements for standard resume template."""
|
| 56 |
+
styles = getSampleStyleSheet()
|
| 57 |
+
elements = []
|
| 58 |
+
|
| 59 |
+
# Custom styles
|
| 60 |
+
styles.add(ParagraphStyle(
|
| 61 |
+
name='Name',
|
| 62 |
+
parent=styles['Heading1'],
|
| 63 |
+
fontSize=24,
|
| 64 |
+
spaceAfter=12,
|
| 65 |
+
alignment=TA_CENTER
|
| 66 |
+
))
|
| 67 |
+
|
| 68 |
+
styles.add(ParagraphStyle(
|
| 69 |
+
name='Contact',
|
| 70 |
+
parent=styles['Normal'],
|
| 71 |
+
fontSize=10,
|
| 72 |
+
spaceAfter=30,
|
| 73 |
+
alignment=TA_CENTER
|
| 74 |
+
))
|
| 75 |
+
|
| 76 |
+
styles.add(ParagraphStyle(
|
| 77 |
+
name='SectionTitle',
|
| 78 |
+
parent=styles['Heading2'],
|
| 79 |
+
fontSize=14,
|
| 80 |
+
spaceBefore=20,
|
| 81 |
+
spaceAfter=10,
|
| 82 |
+
borderWidth=1,
|
| 83 |
+
borderColor=grey,
|
| 84 |
+
borderPadding=5
|
| 85 |
+
))
|
| 86 |
+
|
| 87 |
+
styles.add(ParagraphStyle(
|
| 88 |
+
name='JobTitle',
|
| 89 |
+
parent=styles['Heading3'],
|
| 90 |
+
fontSize=12,
|
| 91 |
+
spaceAfter=2
|
| 92 |
+
))
|
| 93 |
+
|
| 94 |
+
styles.add(ParagraphStyle(
|
| 95 |
+
name='Company',
|
| 96 |
+
parent=styles['Normal'],
|
| 97 |
+
fontSize=11,
|
| 98 |
+
textColor=grey,
|
| 99 |
+
spaceAfter=5
|
| 100 |
+
))
|
| 101 |
+
|
| 102 |
+
styles.add(ParagraphStyle(
|
| 103 |
+
name='Date',
|
| 104 |
+
parent=styles['Normal'],
|
| 105 |
+
fontSize=10,
|
| 106 |
+
textColor=grey,
|
| 107 |
+
alignment=TA_RIGHT
|
| 108 |
+
))
|
| 109 |
+
|
| 110 |
+
# Name
|
| 111 |
+
elements.append(Paragraph(data.get('name', ''), styles['Name']))
|
| 112 |
+
|
| 113 |
+
# Contact info
|
| 114 |
+
contact_info = []
|
| 115 |
+
if data.get('email'):
|
| 116 |
+
contact_info.append(f"Email: {data['email']}")
|
| 117 |
+
if data.get('phone'):
|
| 118 |
+
contact_info.append(f"Phone: {data['phone']}")
|
| 119 |
+
if data.get('linkedin'):
|
| 120 |
+
contact_info.append(f"LinkedIn: {data['linkedin']}")
|
| 121 |
+
if data.get('github'):
|
| 122 |
+
contact_info.append(f"GitHub: {data['github']}")
|
| 123 |
+
|
| 124 |
+
if contact_info:
|
| 125 |
+
elements.append(Paragraph(" | ".join(contact_info), styles['Contact']))
|
| 126 |
+
|
| 127 |
+
# Profile Summary
|
| 128 |
+
if data.get('summary'):
|
| 129 |
+
elements.append(Paragraph("Profile Summary", styles['SectionTitle']))
|
| 130 |
+
elements.append(Paragraph(data['summary'], styles['Normal']))
|
| 131 |
+
|
| 132 |
+
# Add sections based on order
|
| 133 |
+
sections_order = data.get('sections_order', [])
|
| 134 |
+
|
| 135 |
+
for section in sections_order:
|
| 136 |
+
if section == 'work_experience' and data.get('work_experience'):
|
| 137 |
+
elements.append(Paragraph("Work Experience", styles['SectionTitle']))
|
| 138 |
+
for exp in data['work_experience']:
|
| 139 |
+
elements.append(Paragraph(exp.get('title', ''), styles['JobTitle']))
|
| 140 |
+
elements.append(Paragraph(exp.get('organization', ''), styles['Company']))
|
| 141 |
+
|
| 142 |
+
# Create table for date and remarks
|
| 143 |
+
date_data = [[
|
| 144 |
+
Paragraph(f"{exp.get('start_date', '')} - {exp.get('end_date', 'Present')}", styles['Date']),
|
| 145 |
+
Paragraph(exp.get('remarks', ''), styles['Normal'])
|
| 146 |
+
]]
|
| 147 |
+
date_table = Table(date_data, colWidths=[2*inch, 4*inch])
|
| 148 |
+
date_table.setStyle(TableStyle([
|
| 149 |
+
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
| 150 |
+
]))
|
| 151 |
+
elements.append(date_table)
|
| 152 |
+
elements.append(Spacer(1, 10))
|
| 153 |
+
|
| 154 |
+
elif section == 'projects' and data.get('projects'):
|
| 155 |
+
elements.append(Paragraph("Projects", styles['SectionTitle']))
|
| 156 |
+
for proj in data['projects']:
|
| 157 |
+
elements.append(Paragraph(proj.get('title', ''), styles['JobTitle']))
|
| 158 |
+
elements.append(Paragraph(proj.get('organization', ''), styles['Company']))
|
| 159 |
+
|
| 160 |
+
date_data = [[
|
| 161 |
+
Paragraph(f"{proj.get('start_date', '')} - {proj.get('end_date', 'Present')}", styles['Date']),
|
| 162 |
+
Paragraph(proj.get('remarks', ''), styles['Normal'])
|
| 163 |
+
]]
|
| 164 |
+
date_table = Table(date_data, colWidths=[2*inch, 4*inch])
|
| 165 |
+
date_table.setStyle(TableStyle([
|
| 166 |
+
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
| 167 |
+
]))
|
| 168 |
+
elements.append(date_table)
|
| 169 |
+
elements.append(Spacer(1, 10))
|
| 170 |
+
|
| 171 |
+
elif section == 'education' and data.get('education'):
|
| 172 |
+
elements.append(Paragraph("Education", styles['SectionTitle']))
|
| 173 |
+
for edu in data['education']:
|
| 174 |
+
elements.append(Paragraph(edu.get('title', ''), styles['JobTitle']))
|
| 175 |
+
elements.append(Paragraph(edu.get('organization', ''), styles['Company']))
|
| 176 |
+
|
| 177 |
+
date_data = [[
|
| 178 |
+
Paragraph(f"{edu.get('start_date', '')} - {edu.get('end_date', 'Present')}", styles['Date']),
|
| 179 |
+
Paragraph(edu.get('remarks', ''), styles['Normal'])
|
| 180 |
+
]]
|
| 181 |
+
date_table = Table(date_data, colWidths=[2*inch, 4*inch])
|
| 182 |
+
date_table.setStyle(TableStyle([
|
| 183 |
+
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
| 184 |
+
]))
|
| 185 |
+
elements.append(date_table)
|
| 186 |
+
elements.append(Spacer(1, 10))
|
| 187 |
+
|
| 188 |
+
elif section == 'skills' and data.get('skills'):
|
| 189 |
+
elements.append(Paragraph("Skills", styles['SectionTitle']))
|
| 190 |
+
skills_list = [skill.strip() for skill in data['skills'].split(',')]
|
| 191 |
+
elements.append(Paragraph(", ".join(skills_list), styles['Normal']))
|
| 192 |
+
|
| 193 |
+
elif section == 'achievements' and data.get('achievements'):
|
| 194 |
+
elements.append(Paragraph("Achievements", styles['SectionTitle']))
|
| 195 |
+
achievements_list = [achievement.strip() for achievement in data['achievements'].split(',')]
|
| 196 |
+
elements.append(Paragraph(", ".join(achievements_list), styles['Normal']))
|
| 197 |
+
|
| 198 |
+
return elements
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _create_modern_resume_elements(data):
|
| 202 |
+
"""Create elements for modern resume template (two-column)."""
|
| 203 |
+
styles = getSampleStyleSheet()
|
| 204 |
+
elements = []
|
| 205 |
+
|
| 206 |
+
# Custom styles
|
| 207 |
+
styles.add(ParagraphStyle(
|
| 208 |
+
name='Name',
|
| 209 |
+
parent=styles['Heading1'],
|
| 210 |
+
fontSize=20,
|
| 211 |
+
spaceAfter=12,
|
| 212 |
+
alignment=TA_CENTER
|
| 213 |
+
))
|
| 214 |
+
|
| 215 |
+
styles.add(ParagraphStyle(
|
| 216 |
+
name='Contact',
|
| 217 |
+
parent=styles['Normal'],
|
| 218 |
+
fontSize=9,
|
| 219 |
+
spaceAfter=20,
|
| 220 |
+
alignment=TA_CENTER
|
| 221 |
+
))
|
| 222 |
+
|
| 223 |
+
styles.add(ParagraphStyle(
|
| 224 |
+
name='SectionTitle',
|
| 225 |
+
parent=styles['Heading2'],
|
| 226 |
+
fontSize=12,
|
| 227 |
+
spaceBefore=15,
|
| 228 |
+
spaceAfter=8,
|
| 229 |
+
textColor=black
|
| 230 |
+
))
|
| 231 |
+
|
| 232 |
+
styles.add(ParagraphStyle(
|
| 233 |
+
name='JobTitle',
|
| 234 |
+
parent=styles['Heading3'],
|
| 235 |
+
fontSize=11,
|
| 236 |
+
spaceAfter=2
|
| 237 |
+
))
|
| 238 |
+
|
| 239 |
+
styles.add(ParagraphStyle(
|
| 240 |
+
name='Company',
|
| 241 |
+
parent=styles['Normal'],
|
| 242 |
+
fontSize=10,
|
| 243 |
+
textColor=grey,
|
| 244 |
+
spaceAfter=5
|
| 245 |
+
))
|
| 246 |
+
|
| 247 |
+
styles.add(ParagraphStyle(
|
| 248 |
+
name='Date',
|
| 249 |
+
parent=styles['Normal'],
|
| 250 |
+
fontSize=9,
|
| 251 |
+
textColor=grey,
|
| 252 |
+
alignment=TA_RIGHT
|
| 253 |
+
))
|
| 254 |
+
|
| 255 |
+
# Header (full width)
|
| 256 |
+
elements.append(Paragraph(data.get('name', ''), styles['Name']))
|
| 257 |
+
|
| 258 |
+
# Contact info
|
| 259 |
+
contact_info = []
|
| 260 |
+
if data.get('email'):
|
| 261 |
+
contact_info.append(f"Email: {data['email']}")
|
| 262 |
+
if data.get('phone'):
|
| 263 |
+
contact_info.append(f"Phone: {data['phone']}")
|
| 264 |
+
if data.get('linkedin'):
|
| 265 |
+
contact_info.append(f"LinkedIn: {data['linkedin']}")
|
| 266 |
+
if data.get('github'):
|
| 267 |
+
contact_info.append(f"GitHub: {data['github']}")
|
| 268 |
+
|
| 269 |
+
if contact_info:
|
| 270 |
+
elements.append(Paragraph(" | ".join(contact_info), styles['Contact']))
|
| 271 |
+
|
| 272 |
+
# Two-column layout
|
| 273 |
+
left_col_width = 2.5 * inch
|
| 274 |
+
right_col_width = 4.5 * inch
|
| 275 |
+
|
| 276 |
+
# Main content (right column)
|
| 277 |
+
main_elements = []
|
| 278 |
+
|
| 279 |
+
# Profile Summary
|
| 280 |
+
if data.get('summary'):
|
| 281 |
+
main_elements.append(Paragraph("Profile Summary", styles['SectionTitle']))
|
| 282 |
+
main_elements.append(Paragraph(data['summary'], styles['Normal']))
|
| 283 |
+
|
| 284 |
+
# Add sections to main content
|
| 285 |
+
sections_order = data.get('sections_order', [])
|
| 286 |
+
|
| 287 |
+
for section in sections_order:
|
| 288 |
+
if section == 'work_experience' and data.get('work_experience'):
|
| 289 |
+
main_elements.append(Paragraph("Work Experience", styles['SectionTitle']))
|
| 290 |
+
for exp in data['work_experience']:
|
| 291 |
+
main_elements.append(Paragraph(exp.get('title', ''), styles['JobTitle']))
|
| 292 |
+
main_elements.append(Paragraph(exp.get('organization', ''), styles['Company']))
|
| 293 |
+
main_elements.append(Paragraph(
|
| 294 |
+
f"{exp.get('start_date', '')} - {exp.get('end_date', 'Present')}",
|
| 295 |
+
styles['Date']
|
| 296 |
+
))
|
| 297 |
+
main_elements.append(Paragraph(exp.get('remarks', ''), styles['Normal']))
|
| 298 |
+
main_elements.append(Spacer(1, 10))
|
| 299 |
+
|
| 300 |
+
elif section == 'projects' and data.get('projects'):
|
| 301 |
+
main_elements.append(Paragraph("Projects", styles['SectionTitle']))
|
| 302 |
+
for proj in data['projects']:
|
| 303 |
+
main_elements.append(Paragraph(proj.get('title', ''), styles['JobTitle']))
|
| 304 |
+
main_elements.append(Paragraph(proj.get('organization', ''), styles['Company']))
|
| 305 |
+
main_elements.append(Paragraph(
|
| 306 |
+
f"{proj.get('start_date', '')} - {proj.get('end_date', 'Present')}",
|
| 307 |
+
styles['Date']
|
| 308 |
+
))
|
| 309 |
+
main_elements.append(Paragraph(proj.get('remarks', ''), styles['Normal']))
|
| 310 |
+
main_elements.append(Spacer(1, 10))
|
| 311 |
+
|
| 312 |
+
elif section == 'education' and data.get('education'):
|
| 313 |
+
main_elements.append(Paragraph("Education", styles['SectionTitle']))
|
| 314 |
+
for edu in data['education']:
|
| 315 |
+
main_elements.append(Paragraph(edu.get('title', ''), styles['JobTitle']))
|
| 316 |
+
main_elements.append(Paragraph(edu.get('organization', ''), styles['Company']))
|
| 317 |
+
main_elements.append(Paragraph(
|
| 318 |
+
f"{edu.get('start_date', '')} - {edu.get('end_date', 'Present')}",
|
| 319 |
+
styles['Date']
|
| 320 |
+
))
|
| 321 |
+
main_elements.append(Paragraph(edu.get('remarks', ''), styles['Normal']))
|
| 322 |
+
main_elements.append(Spacer(1, 10))
|
| 323 |
+
|
| 324 |
+
# Sidebar (left column)
|
| 325 |
+
sidebar_elements = []
|
| 326 |
+
|
| 327 |
+
# Skills
|
| 328 |
+
if data.get('skills'):
|
| 329 |
+
sidebar_elements.append(Paragraph("Skills", styles['SectionTitle']))
|
| 330 |
+
skills_list = [skill.strip() for skill in data['skills'].split(',')]
|
| 331 |
+
for skill in skills_list:
|
| 332 |
+
sidebar_elements.append(Paragraph(f"• {skill}", styles['Normal']))
|
| 333 |
+
sidebar_elements.append(Spacer(1, 10))
|
| 334 |
+
|
| 335 |
+
# Achievements
|
| 336 |
+
if data.get('achievements'):
|
| 337 |
+
sidebar_elements.append(Paragraph("Achievements", styles['SectionTitle']))
|
| 338 |
+
achievements_list = [achievement.strip() for achievement in data['achievements'].split(',')]
|
| 339 |
+
for achievement in achievements_list:
|
| 340 |
+
sidebar_elements.append(Paragraph(f"• {achievement}", styles['Normal']))
|
| 341 |
+
|
| 342 |
+
# Create two-column table
|
| 343 |
+
all_elements = []
|
| 344 |
+
max_len = max(len(main_elements), len(sidebar_elements))
|
| 345 |
+
|
| 346 |
+
for i in range(max_len):
|
| 347 |
+
left = sidebar_elements[i] if i < len(sidebar_elements) else Spacer(1, 1)
|
| 348 |
+
right = main_elements[i] if i < len(main_elements) else Spacer(1, 1)
|
| 349 |
+
all_elements.append([left, right])
|
| 350 |
+
|
| 351 |
+
if all_elements:
|
| 352 |
+
col_table = Table(all_elements, colWidths=[left_col_width, right_col_width])
|
| 353 |
+
col_table.setStyle(TableStyle([
|
| 354 |
+
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
| 355 |
+
]))
|
| 356 |
+
elements.append(col_table)
|
| 357 |
+
|
| 358 |
+
return elements
|
requirements.txt
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/requirements.txt
|
| 2 |
+
# Core web framework
|
| 3 |
+
Flask==3.1.2
|
| 4 |
+
|
| 5 |
+
# Auth + JWT
|
| 6 |
+
Flask-JWT-Extended==4.7.1
|
| 7 |
+
Flask-Login==0.6.3
|
| 8 |
+
|
| 9 |
+
# Redis client
|
| 10 |
+
redis==6.4.0
|
| 11 |
+
|
| 12 |
+
# Environment variable loader
|
| 13 |
+
python-dotenv==1.1.1
|
| 14 |
+
|
| 15 |
+
# DB (Postgres driver) + ORM & migrations (if you use Postgres)
|
| 16 |
+
psycopg2-binary==2.9.10
|
| 17 |
+
SQLAlchemy==2.0.43
|
| 18 |
+
alembic==1.16.5
|
| 19 |
+
|
| 20 |
+
# Password hashing
|
| 21 |
+
passlib==1.7.4
|
| 22 |
+
bcrypt==4.3.0
|
| 23 |
+
|
| 24 |
+
# PDF generation
|
| 25 |
+
# reportlab==4.0.4
|
| 26 |
+
|
| 27 |
+
# Word (DOCX) generation
|
| 28 |
+
python-docx==1.2.0
|
| 29 |
+
|
| 30 |
+
# Asynchronous file helpers (optional, useful for file writes)
|
| 31 |
+
aiofiles==24.1.0
|
| 32 |
+
|
| 33 |
+
# OpenAI for profile summarization
|
| 34 |
+
openai==1.107.1
|
| 35 |
+
|
| 36 |
+
# Optional: utilities and production server
|
| 37 |
+
python-multipart==0.0.20 # if you accept multipart uploads
|
| 38 |
+
gunicorn==23.0.0
|
| 39 |
+
|
| 40 |
+
# Dev / testing tools
|
| 41 |
+
pytest==8.4.2
|
| 42 |
+
pytest-cov==7.0.0
|
| 43 |
+
|
| 44 |
+
# HTTP clients (if needed)
|
| 45 |
+
httpx==0.28.1
|
| 46 |
+
|
| 47 |
+
# Optional helpers
|
| 48 |
+
boto3==1.40.28 # only if you later switch to S3 (currently not required)
|
| 49 |
+
|
| 50 |
+
flask-cors==6.0.1
|
| 51 |
+
requests==2.32.5
|
| 52 |
+
|
| 53 |
+
email-validator==2.3.0
|
| 54 |
+
Flask-Login==0.6.3
|
| 55 |
+
requests-oauthlib==2.0.0
|
| 56 |
+
Flask-SQLAlchemy==3.1.1
|
| 57 |
+
reportlab==4.4.3
|
templates/admin.html
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Admin Panel - AI Resume Builder</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
.navbar-brand {
|
| 11 |
+
font-weight: bold;
|
| 12 |
+
}
|
| 13 |
+
.table-actions {
|
| 14 |
+
white-space: nowrap;
|
| 15 |
+
}
|
| 16 |
+
.user-count {
|
| 17 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 18 |
+
color: white;
|
| 19 |
+
padding: 20px;
|
| 20 |
+
border-radius: 10px;
|
| 21 |
+
margin-bottom: 30px;
|
| 22 |
+
}
|
| 23 |
+
.delete-btn {
|
| 24 |
+
transition: all 0.3s ease;
|
| 25 |
+
}
|
| 26 |
+
.delete-btn:hover {
|
| 27 |
+
transform: scale(1.1);
|
| 28 |
+
}
|
| 29 |
+
.admin-badge {
|
| 30 |
+
background-color: #dc3545;
|
| 31 |
+
color: white;
|
| 32 |
+
padding: 4px 8px;
|
| 33 |
+
border-radius: 4px;
|
| 34 |
+
font-size: 0.75rem;
|
| 35 |
+
}
|
| 36 |
+
.user-badge {
|
| 37 |
+
background-color: #6c757d;
|
| 38 |
+
color: white;
|
| 39 |
+
padding: 4px 8px;
|
| 40 |
+
border-radius: 4px;
|
| 41 |
+
font-size: 0.75rem;
|
| 42 |
+
}
|
| 43 |
+
</style>
|
| 44 |
+
</head>
|
| 45 |
+
<body>
|
| 46 |
+
<!-- Navigation -->
|
| 47 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
| 48 |
+
<div class="container">
|
| 49 |
+
<a class="navbar-brand" href="{{ url_for('admin_panel') }}">
|
| 50 |
+
<i class="bi bi-shield-lock"></i> Admin Panel
|
| 51 |
+
</a>
|
| 52 |
+
<div class="navbar-nav ms-auto">
|
| 53 |
+
<a class="nav-link" href="{{ url_for('profile') }}">
|
| 54 |
+
<i class="bi bi-person-circle"></i> Back to Profile
|
| 55 |
+
</a>
|
| 56 |
+
<a class="nav-link" href="{{ url_for('logout') }}">
|
| 57 |
+
<i class="bi bi-box-arrow-right"></i> Logout
|
| 58 |
+
</a>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
</nav>
|
| 62 |
+
|
| 63 |
+
<!-- Main Content -->
|
| 64 |
+
<div class="container mt-4">
|
| 65 |
+
<!-- User Statistics -->
|
| 66 |
+
<div class="user-count text-center">
|
| 67 |
+
<h2><i class="bi bi-people-fill"></i> User Management</h2>
|
| 68 |
+
<p class="mb-0 h4">Total Users: {{ users|length }}</p>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<!-- Users Table -->
|
| 72 |
+
<div class="card">
|
| 73 |
+
<div class="card-header bg-primary text-white">
|
| 74 |
+
<h5 class="mb-0"><i class="bi bi-table"></i> Registered Users</h5>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="card-body">
|
| 77 |
+
<div class="table-responsive">
|
| 78 |
+
<table class="table table-hover" id="usersTable">
|
| 79 |
+
<thead>
|
| 80 |
+
<tr>
|
| 81 |
+
<th>ID</th>
|
| 82 |
+
<th>Name</th>
|
| 83 |
+
<th>Email</th>
|
| 84 |
+
<th>Role</th>
|
| 85 |
+
<th>Created At</th>
|
| 86 |
+
<th>Actions</th>
|
| 87 |
+
</tr>
|
| 88 |
+
</thead>
|
| 89 |
+
<tbody>
|
| 90 |
+
{% for user in users %}
|
| 91 |
+
<tr data-user-id="{{ user.id }}">
|
| 92 |
+
<td><code>{{ user.id|string|truncate(8, True, '') }}...</code></td>
|
| 93 |
+
<td>{{ user.name }}</td>
|
| 94 |
+
<td>{{ user.email }}</td>
|
| 95 |
+
<td>
|
| 96 |
+
{% if user.is_admin %}
|
| 97 |
+
<span class="admin-badge">Admin</span>
|
| 98 |
+
{% else %}
|
| 99 |
+
<span class="user-badge">User</span>
|
| 100 |
+
{% endif %}
|
| 101 |
+
</td>
|
| 102 |
+
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'N/A' }}</td>
|
| 103 |
+
<td class="table-actions">
|
| 104 |
+
{% if not user.is_admin %}
|
| 105 |
+
<button class="btn btn-sm btn-danger delete-btn"
|
| 106 |
+
onclick="deleteUser('{{ user.id }}', '{{ user.email }}')"
|
| 107 |
+
title="Delete User">
|
| 108 |
+
<i class="bi bi-trash"></i>
|
| 109 |
+
</button>
|
| 110 |
+
{% endif %}
|
| 111 |
+
</td>
|
| 112 |
+
</tr>
|
| 113 |
+
{% endfor %}
|
| 114 |
+
</tbody>
|
| 115 |
+
</table>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<!-- Delete Confirmation Modal -->
|
| 122 |
+
<div class="modal fade" id="deleteModal" tabindex="-1">
|
| 123 |
+
<div class="modal-dialog">
|
| 124 |
+
<div class="modal-content">
|
| 125 |
+
<div class="modal-header bg-danger text-white">
|
| 126 |
+
<h5 class="modal-title">
|
| 127 |
+
<i class="bi bi-exclamation-triangle"></i> Confirm Delete
|
| 128 |
+
</h5>
|
| 129 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
| 130 |
+
</div>
|
| 131 |
+
<div class="modal-body">
|
| 132 |
+
<p>Are you sure you want to delete this user?</p>
|
| 133 |
+
<div class="alert alert-warning">
|
| 134 |
+
<i class="bi bi-warning"></i>
|
| 135 |
+
<strong>Warning:</strong> This will permanently delete the user and all their profile data including:
|
| 136 |
+
<ul class="mb-0 mt-2">
|
| 137 |
+
<li>Introduction information</li>
|
| 138 |
+
<li>Profile summary</li>
|
| 139 |
+
<li>Work experience</li>
|
| 140 |
+
<li>Projects</li>
|
| 141 |
+
<li>Education</li>
|
| 142 |
+
<li>Skills</li>
|
| 143 |
+
<li>Achievements</li>
|
| 144 |
+
</ul>
|
| 145 |
+
</div>
|
| 146 |
+
<p><strong>User to delete:</strong> <span id="deleteUserEmail"></span></p>
|
| 147 |
+
</div>
|
| 148 |
+
<div class="modal-footer">
|
| 149 |
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
| 150 |
+
<button type="button" class="btn btn-danger" id="confirmDelete">
|
| 151 |
+
<i class="bi bi-trash"></i> Delete User
|
| 152 |
+
</button>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<!-- Success Toast -->
|
| 159 |
+
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
| 160 |
+
<div id="successToast" class="toast" role="alert">
|
| 161 |
+
<div class="toast-header bg-success text-white">
|
| 162 |
+
<i class="bi bi-check-circle me-2"></i>
|
| 163 |
+
<strong class="me-auto">Success</strong>
|
| 164 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
|
| 165 |
+
</div>
|
| 166 |
+
<div class="toast-body" id="successMessage">
|
| 167 |
+
User deleted successfully!
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<!-- Error Toast -->
|
| 173 |
+
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
| 174 |
+
<div id="errorToast" class="toast" role="alert">
|
| 175 |
+
<div class="toast-header bg-danger text-white">
|
| 176 |
+
<i class="bi bi-exclamation-circle me-2"></i>
|
| 177 |
+
<strong class="me-auto">Error</strong>
|
| 178 |
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
|
| 179 |
+
</div>
|
| 180 |
+
<div class="toast-body" id="errorMessage">
|
| 181 |
+
An error occurred!
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 187 |
+
<script>
|
| 188 |
+
let userToDelete = null;
|
| 189 |
+
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
| 190 |
+
const successToast = new bootstrap.Toast(document.getElementById('successToast'));
|
| 191 |
+
const errorToast = new bootstrap.Toast(document.getElementById('errorToast'));
|
| 192 |
+
|
| 193 |
+
function deleteUser(userId, userEmail) {
|
| 194 |
+
userToDelete = userId;
|
| 195 |
+
document.getElementById('deleteUserEmail').textContent = userEmail;
|
| 196 |
+
deleteModal.show();
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
document.getElementById('confirmDelete').addEventListener('click', async function() {
|
| 200 |
+
if (!userToDelete) return;
|
| 201 |
+
|
| 202 |
+
try {
|
| 203 |
+
const response = await fetch(`/api/admin/users/${userToDelete}`, {
|
| 204 |
+
method: 'DELETE',
|
| 205 |
+
headers: {
|
| 206 |
+
'Content-Type': 'application/json'
|
| 207 |
+
}
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
const data = await response.json();
|
| 211 |
+
|
| 212 |
+
if (data.success) {
|
| 213 |
+
// Remove row from table
|
| 214 |
+
const row = document.querySelector(`tr[data-user-id="${userToDelete}"]`);
|
| 215 |
+
if (row) {
|
| 216 |
+
row.remove();
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// Update user count
|
| 220 |
+
const userCount = document.querySelectorAll('#usersTable tbody tr').length;
|
| 221 |
+
document.querySelector('.user-count p').textContent = `Total Users: ${userCount}`;
|
| 222 |
+
|
| 223 |
+
// Show success message
|
| 224 |
+
document.getElementById('successMessage').textContent = data.message;
|
| 225 |
+
successToast.show();
|
| 226 |
+
} else {
|
| 227 |
+
// Show error message
|
| 228 |
+
document.getElementById('errorMessage').textContent = data.error || 'Failed to delete user';
|
| 229 |
+
errorToast.show();
|
| 230 |
+
}
|
| 231 |
+
} catch (error) {
|
| 232 |
+
console.error('Error:', error);
|
| 233 |
+
document.getElementById('errorMessage').textContent = 'Network error occurred';
|
| 234 |
+
errorToast.show();
|
| 235 |
+
} finally {
|
| 236 |
+
deleteModal.hide();
|
| 237 |
+
userToDelete = null;
|
| 238 |
+
}
|
| 239 |
+
});
|
| 240 |
+
</script>
|
| 241 |
+
</body>
|
| 242 |
+
</html>
|
templates/base.html
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}AI Resume Builder{% endblock %}</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<style>
|
| 9 |
+
/* Custom animations */
|
| 10 |
+
@keyframes fadeIn {
|
| 11 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 12 |
+
to { opacity: 1; transform: translateY(0); }
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.fade-in {
|
| 16 |
+
animation: fadeIn 0.5s ease-out;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* Carousel transitions */
|
| 20 |
+
.carousel-image {
|
| 21 |
+
transition: opacity 1s ease-in-out;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/* Custom scrollbar */
|
| 25 |
+
::-webkit-scrollbar {
|
| 26 |
+
width: 8px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
::-webkit-scrollbar-track {
|
| 30 |
+
background: #f1f1f1;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
::-webkit-scrollbar-thumb {
|
| 34 |
+
background: #888;
|
| 35 |
+
border-radius: 4px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
::-webkit-scrollbar-thumb:hover {
|
| 39 |
+
background: #555;
|
| 40 |
+
}
|
| 41 |
+
</style>
|
| 42 |
+
</head>
|
| 43 |
+
<body class="bg-gray-50">
|
| 44 |
+
<!-- Flash Messages -->
|
| 45 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 46 |
+
{% if messages %}
|
| 47 |
+
<div class="fixed top-4 right-4 z-50 space-y-2">
|
| 48 |
+
{% for category, message in messages %}
|
| 49 |
+
<div class="fade-in px-4 py-3 rounded-lg shadow-lg {% if category == 'success' %}bg-green-50 border border-green-200 text-green-800{% elif category == 'error' %}bg-red-50 border border-red-200 text-red-800{% else %}bg-blue-50 border border-blue-200 text-blue-800{% endif %}">
|
| 50 |
+
<div class="flex items-center">
|
| 51 |
+
<span class="text-sm font-medium">{{ message }}</span>
|
| 52 |
+
<button onclick="this.parentElement.parentElement.remove()" class="ml-3 text-gray-400 hover:text-gray-600">
|
| 53 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 54 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
| 55 |
+
</svg>
|
| 56 |
+
</button>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
{% endfor %}
|
| 60 |
+
</div>
|
| 61 |
+
{% endif %}
|
| 62 |
+
{% endwith %}
|
| 63 |
+
|
| 64 |
+
<!-- Main Content -->
|
| 65 |
+
<main class="min-h-screen">
|
| 66 |
+
{% block content %}{% endblock %}
|
| 67 |
+
</main>
|
| 68 |
+
|
| 69 |
+
<!-- Footer -->
|
| 70 |
+
{% block footer %}
|
| 71 |
+
<footer class="bg-white border-t border-gray-200 mt-12">
|
| 72 |
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 73 |
+
<div class="text-center text-gray-600">
|
| 74 |
+
<p class="text-sm">© 2024 AI Resume Builder. Built with ❤️ using Flask and AI.</p>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</footer>
|
| 78 |
+
{% endblock %}
|
| 79 |
+
|
| 80 |
+
<!-- JavaScript -->
|
| 81 |
+
<script>
|
| 82 |
+
// Auto-hide flash messages after 5 seconds
|
| 83 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 84 |
+
const messages = document.querySelectorAll('.fixed.top-4 > div');
|
| 85 |
+
messages.forEach(function(message) {
|
| 86 |
+
setTimeout(function() {
|
| 87 |
+
message.style.opacity = '0';
|
| 88 |
+
message.style.transform = 'translateX(100%)';
|
| 89 |
+
message.style.transition = 'all 0.3s ease-out';
|
| 90 |
+
setTimeout(function() {
|
| 91 |
+
message.remove();
|
| 92 |
+
}, 300);
|
| 93 |
+
}, 5000);
|
| 94 |
+
});
|
| 95 |
+
});
|
| 96 |
+
</script>
|
| 97 |
+
|
| 98 |
+
{% block scripts %}{% endblock %}
|
| 99 |
+
</body>
|
| 100 |
+
</html>
|
templates/create_achievements.html
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Achievements - AI Resume Builder</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
background-color: #f8f9fa;
|
| 12 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
.form-container {
|
| 15 |
+
max-width: 800px;
|
| 16 |
+
margin: 40px auto;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
background: white;
|
| 19 |
+
border-radius: 10px;
|
| 20 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 21 |
+
}
|
| 22 |
+
.section-title {
|
| 23 |
+
color: #2c3e50;
|
| 24 |
+
margin-bottom: 30px;
|
| 25 |
+
text-align: center;
|
| 26 |
+
font-weight: 600;
|
| 27 |
+
}
|
| 28 |
+
.btn-logout {
|
| 29 |
+
position: absolute;
|
| 30 |
+
top: 20px;
|
| 31 |
+
right: 20px;
|
| 32 |
+
}
|
| 33 |
+
.form-label {
|
| 34 |
+
font-weight: 500;
|
| 35 |
+
margin-bottom: 8px;
|
| 36 |
+
color: #495057;
|
| 37 |
+
}
|
| 38 |
+
.form-control:focus {
|
| 39 |
+
border-color: #4e73df;
|
| 40 |
+
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
|
| 41 |
+
}
|
| 42 |
+
.navigation-buttons {
|
| 43 |
+
display: flex;
|
| 44 |
+
justify-content: space-between;
|
| 45 |
+
margin-top: 30px;
|
| 46 |
+
padding-top: 20px;
|
| 47 |
+
border-top: 1px solid #e9ecef;
|
| 48 |
+
}
|
| 49 |
+
.error-message {
|
| 50 |
+
color: #e74c3c;
|
| 51 |
+
font-size: 0.875rem;
|
| 52 |
+
margin-top: 5px;
|
| 53 |
+
}
|
| 54 |
+
.progress-indicator {
|
| 55 |
+
margin-bottom: 30px;
|
| 56 |
+
text-align: center;
|
| 57 |
+
color: #6c757d;
|
| 58 |
+
font-size: 0.9rem;
|
| 59 |
+
}
|
| 60 |
+
.progress-indicator span {
|
| 61 |
+
display: inline-block;
|
| 62 |
+
margin: 0 10px;
|
| 63 |
+
}
|
| 64 |
+
.progress-indicator span.active {
|
| 65 |
+
color: #4e73df;
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
}
|
| 68 |
+
.achievements-display {
|
| 69 |
+
background-color: #f8f9fa;
|
| 70 |
+
border: 1px solid #e9ecef;
|
| 71 |
+
border-radius: 8px;
|
| 72 |
+
padding: 20px;
|
| 73 |
+
margin-top: 20px;
|
| 74 |
+
min-height: 100px;
|
| 75 |
+
}
|
| 76 |
+
.achievement-item {
|
| 77 |
+
display: flex;
|
| 78 |
+
align-items: center;
|
| 79 |
+
background-color: white;
|
| 80 |
+
border: 1px solid #dee2e6;
|
| 81 |
+
border-radius: 6px;
|
| 82 |
+
padding: 12px 15px;
|
| 83 |
+
margin-bottom: 10px;
|
| 84 |
+
transition: all 0.3s;
|
| 85 |
+
}
|
| 86 |
+
.achievement-item:hover {
|
| 87 |
+
border-color: #4e73df;
|
| 88 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 89 |
+
}
|
| 90 |
+
.achievement-item .achievement-icon {
|
| 91 |
+
color: #ffc107;
|
| 92 |
+
margin-right: 15px;
|
| 93 |
+
font-size: 1.2rem;
|
| 94 |
+
}
|
| 95 |
+
.achievement-item .achievement-text {
|
| 96 |
+
flex: 1;
|
| 97 |
+
margin: 0;
|
| 98 |
+
}
|
| 99 |
+
.achievement-item .remove-achievement {
|
| 100 |
+
color: #dc3545;
|
| 101 |
+
cursor: pointer;
|
| 102 |
+
opacity: 0.6;
|
| 103 |
+
transition: opacity 0.3s;
|
| 104 |
+
}
|
| 105 |
+
.achievement-item .remove-achievement:hover {
|
| 106 |
+
opacity: 1;
|
| 107 |
+
}
|
| 108 |
+
.empty-achievements {
|
| 109 |
+
text-align: center;
|
| 110 |
+
color: #6c757d;
|
| 111 |
+
font-style: italic;
|
| 112 |
+
padding: 20px;
|
| 113 |
+
}
|
| 114 |
+
.help-text {
|
| 115 |
+
font-size: 0.875rem;
|
| 116 |
+
color: #6c757d;
|
| 117 |
+
margin-top: 5px;
|
| 118 |
+
}
|
| 119 |
+
.achievements-preview-section {
|
| 120 |
+
margin-top: 30px;
|
| 121 |
+
}
|
| 122 |
+
.achievements-preview-section h5 {
|
| 123 |
+
color: #495057;
|
| 124 |
+
margin-bottom: 15px;
|
| 125 |
+
}
|
| 126 |
+
.form-hint {
|
| 127 |
+
background-color: #e3f2fd;
|
| 128 |
+
border-left: 4px solid #2196f3;
|
| 129 |
+
padding: 12px 15px;
|
| 130 |
+
margin-bottom: 20px;
|
| 131 |
+
border-radius: 4px;
|
| 132 |
+
}
|
| 133 |
+
.form-hint p {
|
| 134 |
+
margin: 0;
|
| 135 |
+
color: #1976d2;
|
| 136 |
+
font-size: 0.9rem;
|
| 137 |
+
}
|
| 138 |
+
</style>
|
| 139 |
+
</head>
|
| 140 |
+
<body>
|
| 141 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
| 142 |
+
<div class="container">
|
| 143 |
+
<a class="navbar-brand" href="{{ url_for('profile') }}">AI Resume Builder</a>
|
| 144 |
+
<div class="ms-auto">
|
| 145 |
+
<a href="{{ url_for('logout') }}" class="btn btn-outline-light">
|
| 146 |
+
<i class="fas fa-sign-out-alt"></i> Logout
|
| 147 |
+
</a>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</nav>
|
| 151 |
+
|
| 152 |
+
<div class="container">
|
| 153 |
+
<div class="form-container">
|
| 154 |
+
<div class="progress-indicator">
|
| 155 |
+
<span>Introduction</span>
|
| 156 |
+
<span>→</span>
|
| 157 |
+
<span>Profile Summary</span>
|
| 158 |
+
<span>→</span>
|
| 159 |
+
<span>Work Experience</span>
|
| 160 |
+
<span>→</span>
|
| 161 |
+
<span>Projects</span>
|
| 162 |
+
<span>→</span>
|
| 163 |
+
<span>Education</span>
|
| 164 |
+
<span>→</span>
|
| 165 |
+
<span>Skills</span>
|
| 166 |
+
<span>→</span>
|
| 167 |
+
<span class="active">Achievements</span>
|
| 168 |
+
<span>→</span>
|
| 169 |
+
<span>Preview</span>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<h2 class="section-title">Achievements</h2>
|
| 173 |
+
|
| 174 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 175 |
+
{% if messages %}
|
| 176 |
+
{% for category, message in messages %}
|
| 177 |
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
| 178 |
+
{{ message }}
|
| 179 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 180 |
+
</div>
|
| 181 |
+
{% endfor %}
|
| 182 |
+
{% endif %}
|
| 183 |
+
{% endwith %}
|
| 184 |
+
|
| 185 |
+
<div class="form-hint">
|
| 186 |
+
<p><i class="fas fa-lightbulb"></i> <strong>Tip:</strong> Highlight your awards, certifications, publications, or other notable accomplishments. These help make your resume stand out!</p>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<form method="POST" action="{{ url_for('create_achievements') }}" id="achievementsForm">
|
| 190 |
+
<div class="mb-3">
|
| 191 |
+
<label for="achievements" class="form-label">Enter Your Achievements</label>
|
| 192 |
+
<textarea class="form-control" id="achievements" name="achievements" rows="5"
|
| 193 |
+
placeholder="Enter your achievements separated by commas. For example: Employee of the Year 2023, AWS Certified Solutions Architect, Published research paper on AI ethics...">{{ form_data.achievements if form_data }}</textarea>
|
| 194 |
+
<div class="help-text">
|
| 195 |
+
Enter your awards, certifications, publications, honors, or other achievements separated by commas. Each will be displayed as a separate item.
|
| 196 |
+
</div>
|
| 197 |
+
{% if form_errors.achievements %}
|
| 198 |
+
<div class="error-message">{{ form_errors.achievements[0] }}</div>
|
| 199 |
+
{% endif %}
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<div class="achievements-preview-section">
|
| 203 |
+
<h5><i class="fas fa-eye"></i> Achievements Preview</h5>
|
| 204 |
+
<div class="achievements-display" id="achievementsPreview">
|
| 205 |
+
{% if form_data.achievements_preview %}
|
| 206 |
+
{% for achievement in form_data.achievements_preview %}
|
| 207 |
+
<div class="achievement-item">
|
| 208 |
+
<i class="fas fa-trophy achievement-icon"></i>
|
| 209 |
+
<p class="achievement-text mb-0">{{ achievement }}</p>
|
| 210 |
+
<i class="fas fa-times remove-achievement" onclick="removeAchievement(this)"></i>
|
| 211 |
+
</div>
|
| 212 |
+
{% endfor %}
|
| 213 |
+
{% else %}
|
| 214 |
+
<div class="empty-achievements">
|
| 215 |
+
Your achievements will appear here as you type them above
|
| 216 |
+
</div>
|
| 217 |
+
{% endif %}
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<div class="navigation-buttons">
|
| 222 |
+
<div>
|
| 223 |
+
<a href="{{ url_for('create_skills') }}" class="btn btn-outline-secondary">
|
| 224 |
+
<i class="fas fa-arrow-left"></i> Back
|
| 225 |
+
</a>
|
| 226 |
+
<button type="button" class="btn btn-outline-primary" onclick="skipToPreview()">
|
| 227 |
+
Skip <i class="fas fa-forward"></i>
|
| 228 |
+
</button>
|
| 229 |
+
</div>
|
| 230 |
+
<div>
|
| 231 |
+
<button type="submit" class="btn btn-primary">
|
| 232 |
+
Next <i class="fas fa-arrow-right"></i>
|
| 233 |
+
</button>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
</form>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 241 |
+
<script>
|
| 242 |
+
const achievementsTextarea = document.getElementById('achievements');
|
| 243 |
+
const achievementsPreview = document.getElementById('achievementsPreview');
|
| 244 |
+
|
| 245 |
+
function updateAchievementsPreview() {
|
| 246 |
+
const input = achievementsTextarea.value;
|
| 247 |
+
const newAchievements = input.split(',').map(achievement => achievement.trim()).filter(achievement => achievement);
|
| 248 |
+
|
| 249 |
+
// Update preview
|
| 250 |
+
if (newAchievements.length === 0) {
|
| 251 |
+
achievementsPreview.innerHTML = '<div class="empty-achievements">Your achievements will appear here as you type them above</div>';
|
| 252 |
+
} else {
|
| 253 |
+
achievementsPreview.innerHTML = newAchievements.map(achievement => `
|
| 254 |
+
<div class="achievement-item">
|
| 255 |
+
<i class="fas fa-trophy achievement-icon"></i>
|
| 256 |
+
<p class="achievement-text mb-0">${achievement}</p>
|
| 257 |
+
<i class="fas fa-times remove-achievement" onclick="removeAchievement(this)"></i>
|
| 258 |
+
</div>
|
| 259 |
+
`).join('');
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
function removeAchievement(element) {
|
| 264 |
+
const achievementItem = element.closest('.achievement-item');
|
| 265 |
+
const achievementText = achievementItem.querySelector('.achievement-text').textContent.trim();
|
| 266 |
+
|
| 267 |
+
// Remove from textarea
|
| 268 |
+
const currentAchievements = achievementsTextarea.value.split(',').map(a => a.trim()).filter(a => a);
|
| 269 |
+
const updatedAchievements = currentAchievements.filter(achievement => achievement !== achievementText);
|
| 270 |
+
achievementsTextarea.value = updatedAchievements.join(', ');
|
| 271 |
+
|
| 272 |
+
// Update preview
|
| 273 |
+
updateAchievementsPreview();
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function skipToPreview() {
|
| 277 |
+
document.getElementById('achievementsForm').action = '{{ url_for("create_preview") }}';
|
| 278 |
+
document.getElementById('achievementsForm').submit();
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// Update preview as user types
|
| 282 |
+
achievementsTextarea.addEventListener('input', updateAchievementsPreview);
|
| 283 |
+
|
| 284 |
+
// Initialize preview on page load
|
| 285 |
+
updateAchievementsPreview();
|
| 286 |
+
</script>
|
| 287 |
+
</body>
|
| 288 |
+
</html>
|
templates/create_education.html
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Education - AI Resume Builder</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
background-color: #f8f9fa;
|
| 12 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
.form-container {
|
| 15 |
+
max-width: 900px;
|
| 16 |
+
margin: 40px auto;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
background: white;
|
| 19 |
+
border-radius: 10px;
|
| 20 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 21 |
+
}
|
| 22 |
+
.section-title {
|
| 23 |
+
color: #2c3e50;
|
| 24 |
+
margin-bottom: 30px;
|
| 25 |
+
text-align: center;
|
| 26 |
+
font-weight: 600;
|
| 27 |
+
}
|
| 28 |
+
.btn-logout {
|
| 29 |
+
position: absolute;
|
| 30 |
+
top: 20px;
|
| 31 |
+
right: 20px;
|
| 32 |
+
}
|
| 33 |
+
.form-label {
|
| 34 |
+
font-weight: 500;
|
| 35 |
+
margin-bottom: 8px;
|
| 36 |
+
color: #495057;
|
| 37 |
+
}
|
| 38 |
+
.form-control:focus {
|
| 39 |
+
border-color: #4e73df;
|
| 40 |
+
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
|
| 41 |
+
}
|
| 42 |
+
.navigation-buttons {
|
| 43 |
+
display: flex;
|
| 44 |
+
justify-content: space-between;
|
| 45 |
+
margin-top: 30px;
|
| 46 |
+
padding-top: 20px;
|
| 47 |
+
border-top: 1px solid #e9ecef;
|
| 48 |
+
}
|
| 49 |
+
.error-message {
|
| 50 |
+
color: #e74c3c;
|
| 51 |
+
font-size: 0.875rem;
|
| 52 |
+
margin-top: 5px;
|
| 53 |
+
}
|
| 54 |
+
.progress-indicator {
|
| 55 |
+
margin-bottom: 30px;
|
| 56 |
+
text-align: center;
|
| 57 |
+
color: #6c757d;
|
| 58 |
+
font-size: 0.9rem;
|
| 59 |
+
}
|
| 60 |
+
.progress-indicator span {
|
| 61 |
+
display: inline-block;
|
| 62 |
+
margin: 0 10px;
|
| 63 |
+
}
|
| 64 |
+
.progress-indicator span.active {
|
| 65 |
+
color: #4e73df;
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
}
|
| 68 |
+
.education-item {
|
| 69 |
+
background-color: #f8f9fa;
|
| 70 |
+
border: 1px solid #e9ecef;
|
| 71 |
+
border-radius: 8px;
|
| 72 |
+
padding: 20px;
|
| 73 |
+
margin-bottom: 20px;
|
| 74 |
+
position: relative;
|
| 75 |
+
}
|
| 76 |
+
.education-item.has-error {
|
| 77 |
+
border-color: #e74c3c;
|
| 78 |
+
background-color: #fdf2f2;
|
| 79 |
+
}
|
| 80 |
+
.btn-remove {
|
| 81 |
+
position: absolute;
|
| 82 |
+
top: 10px;
|
| 83 |
+
right: 10px;
|
| 84 |
+
color: #e74c3c;
|
| 85 |
+
background: white;
|
| 86 |
+
border: 1px solid #e74c3c;
|
| 87 |
+
padding: 5px 10px;
|
| 88 |
+
border-radius: 5px;
|
| 89 |
+
transition: all 0.3s;
|
| 90 |
+
}
|
| 91 |
+
.btn-remove:hover {
|
| 92 |
+
color: white;
|
| 93 |
+
background-color: #e74c3c;
|
| 94 |
+
}
|
| 95 |
+
.btn-add {
|
| 96 |
+
margin-bottom: 20px;
|
| 97 |
+
background-color: #28a745;
|
| 98 |
+
border: none;
|
| 99 |
+
color: white;
|
| 100 |
+
}
|
| 101 |
+
.btn-add:hover {
|
| 102 |
+
background-color: #218838;
|
| 103 |
+
color: white;
|
| 104 |
+
}
|
| 105 |
+
.present-checkbox {
|
| 106 |
+
margin-top: 10px;
|
| 107 |
+
}
|
| 108 |
+
.date-inputs {
|
| 109 |
+
display: flex;
|
| 110 |
+
gap: 10px;
|
| 111 |
+
}
|
| 112 |
+
.date-inputs .form-control {
|
| 113 |
+
flex: 1;
|
| 114 |
+
}
|
| 115 |
+
.empty-state {
|
| 116 |
+
text-align: center;
|
| 117 |
+
padding: 40px;
|
| 118 |
+
color: #6c757d;
|
| 119 |
+
}
|
| 120 |
+
.empty-state i {
|
| 121 |
+
font-size: 3rem;
|
| 122 |
+
margin-bottom: 20px;
|
| 123 |
+
color: #dee2e6;
|
| 124 |
+
}
|
| 125 |
+
.section-header {
|
| 126 |
+
display: flex;
|
| 127 |
+
justify-content: space-between;
|
| 128 |
+
align-items: center;
|
| 129 |
+
margin-bottom: 20px;
|
| 130 |
+
}
|
| 131 |
+
</style>
|
| 132 |
+
</head>
|
| 133 |
+
<body>
|
| 134 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
| 135 |
+
<div class="container">
|
| 136 |
+
<a class="navbar-brand" href="{{ url_for('profile') }}">AI Resume Builder</a>
|
| 137 |
+
<div class="ms-auto">
|
| 138 |
+
<a href="{{ url_for('logout') }}" class="btn btn-outline-light">
|
| 139 |
+
<i class="fas fa-sign-out-alt"></i> Logout
|
| 140 |
+
</a>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</nav>
|
| 144 |
+
|
| 145 |
+
<div class="container">
|
| 146 |
+
<div class="form-container">
|
| 147 |
+
<div class="progress-indicator">
|
| 148 |
+
<span>Introduction</span>
|
| 149 |
+
<span>→</span>
|
| 150 |
+
<span>Profile Summary</span>
|
| 151 |
+
<span>→</span>
|
| 152 |
+
<span>Work Experience</span>
|
| 153 |
+
<span>→</span>
|
| 154 |
+
<span>Projects</span>
|
| 155 |
+
<span>→</span>
|
| 156 |
+
<span class="active">Education</span>
|
| 157 |
+
<span>→</span>
|
| 158 |
+
<span>Skills</span>
|
| 159 |
+
<span>→</span>
|
| 160 |
+
<span>Achievements</span>
|
| 161 |
+
<span>→</span>
|
| 162 |
+
<span>Preview</span>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div class="section-header">
|
| 166 |
+
<h2 class="section-title mb-0">Education</h2>
|
| 167 |
+
<button type="button" class="btn btn-add" onclick="addEducation()">
|
| 168 |
+
<i class="fas fa-plus"></i> Add Education
|
| 169 |
+
</button>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 173 |
+
{% if messages %}
|
| 174 |
+
{% for category, message in messages %}
|
| 175 |
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
| 176 |
+
{{ message }}
|
| 177 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 178 |
+
</div>
|
| 179 |
+
{% endfor %}
|
| 180 |
+
{% endif %}
|
| 181 |
+
{% endwith %}
|
| 182 |
+
|
| 183 |
+
<form method="POST" action="{{ url_for('create_education') }}" id="educationForm">
|
| 184 |
+
<div id="educationContainer">
|
| 185 |
+
{% if form_data and form_data.educations %}
|
| 186 |
+
{% for edu in form_data.educations %}
|
| 187 |
+
<div class="education-item">
|
| 188 |
+
<button type="button" class="btn btn-sm btn-remove" onclick="removeEducation(this)">
|
| 189 |
+
<i class="fas fa-times"></i>
|
| 190 |
+
</button>
|
| 191 |
+
|
| 192 |
+
<div class="row">
|
| 193 |
+
<div class="col-md-6 mb-3">
|
| 194 |
+
<label class="form-label required">Institution</label>
|
| 195 |
+
<input type="text" class="form-control" name="organization[]" value="{{ edu.organization }}" required>
|
| 196 |
+
</div>
|
| 197 |
+
<div class="col-md-6 mb-3">
|
| 198 |
+
<label class="form-label required">Degree/Certification</label>
|
| 199 |
+
<input type="text" class="form-control" name="title[]" value="{{ edu.title }}" required>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div class="row">
|
| 204 |
+
<div class="col-md-6 mb-3">
|
| 205 |
+
<label class="form-label required">Start Date</label>
|
| 206 |
+
<div class="date-inputs">
|
| 207 |
+
<select class="form-control" name="start_month[]" required>
|
| 208 |
+
<option value="">Month</option>
|
| 209 |
+
{% for i in range(1, 13) %}
|
| 210 |
+
<option value="{{ i }}" {% if edu.start_month == i %}selected{% endif %}>
|
| 211 |
+
{{ i }}
|
| 212 |
+
</option>
|
| 213 |
+
{% endfor %}
|
| 214 |
+
</select>
|
| 215 |
+
<select class="form-control" name="start_year[]" required>
|
| 216 |
+
<option value="">Year</option>
|
| 217 |
+
{% for year in range(1980, current_year + 1) %}
|
| 218 |
+
<option value="{{ year }}" {% if edu.start_year == year %}selected{% endif %}>
|
| 219 |
+
{{ year }}
|
| 220 |
+
</option>
|
| 221 |
+
{% endfor %}
|
| 222 |
+
</select>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
<div class="col-md-6 mb-3">
|
| 226 |
+
<label class="form-label">End Date</label>
|
| 227 |
+
<div class="date-inputs">
|
| 228 |
+
<select class="form-control" name="end_month[]" {% if edu.is_present %}disabled{% endif %}>
|
| 229 |
+
<option value="">Month</option>
|
| 230 |
+
{% for i in range(1, 13) %}
|
| 231 |
+
<option value="{{ i }}" {% if edu.end_month == i %}selected{% endif %}>
|
| 232 |
+
{{ i }}
|
| 233 |
+
</option>
|
| 234 |
+
{% endfor %}
|
| 235 |
+
</select>
|
| 236 |
+
<select class="form-control" name="end_year[]" {% if edu.is_present %}disabled{% endif %}>
|
| 237 |
+
<option value="">Year</option>
|
| 238 |
+
{% for year in range(1980, current_year + 1) %}
|
| 239 |
+
<option value="{{ year }}" {% if edu.end_year == year %}selected{% endif %}>
|
| 240 |
+
{{ year }}
|
| 241 |
+
</option>
|
| 242 |
+
{% endfor %}
|
| 243 |
+
</select>
|
| 244 |
+
</div>
|
| 245 |
+
<div class="present-checkbox">
|
| 246 |
+
<div class="form-check">
|
| 247 |
+
<input class="form-check-input" type="checkbox" name="is_present[]" id="present_edu_{{ loop.index0 }}"
|
| 248 |
+
{% if edu.is_present %}checked{% endif %}
|
| 249 |
+
onchange="toggleEndDate(this)">
|
| 250 |
+
<label class="form-check-label" for="present_edu_{{ loop.index0 }}">
|
| 251 |
+
Present
|
| 252 |
+
</label>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
<div class="mb-3">
|
| 259 |
+
<label class="form-label">Remarks</label>
|
| 260 |
+
<textarea class="form-control" name="remarks[]" rows="3" placeholder="Describe your studies, achievements, or relevant coursework...">{{ edu.remarks }}</textarea>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
{% endfor %}
|
| 264 |
+
{% else %}
|
| 265 |
+
<div class="empty-state">
|
| 266 |
+
<i class="fas fa-graduation-cap"></i>
|
| 267 |
+
<h5>No Education Added</h5>
|
| 268 |
+
<p>Click "Add Education" to add your educational background</p>
|
| 269 |
+
</div>
|
| 270 |
+
{% endif %}
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
<div class="navigation-buttons">
|
| 274 |
+
<div>
|
| 275 |
+
<a href="{{ url_for('create_projects') }}" class="btn btn-outline-secondary">
|
| 276 |
+
<i class="fas fa-arrow-left"></i> Back
|
| 277 |
+
</a>
|
| 278 |
+
<button type="button" class="btn btn-outline-primary" onclick="skipToSkills()">
|
| 279 |
+
Skip <i class="fas fa-forward"></i>
|
| 280 |
+
</button>
|
| 281 |
+
</div>
|
| 282 |
+
<div>
|
| 283 |
+
<button type="submit" class="btn btn-primary">
|
| 284 |
+
Next <i class="fas fa-arrow-right"></i>
|
| 285 |
+
</button>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
</form>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 293 |
+
<script>
|
| 294 |
+
let educationCount = {{ form_data.educations|length if form_data and form_data.educations else 0 }};
|
| 295 |
+
|
| 296 |
+
function addEducation() {
|
| 297 |
+
const container = document.getElementById('educationContainer');
|
| 298 |
+
const emptyState = container.querySelector('.empty-state');
|
| 299 |
+
if (emptyState) {
|
| 300 |
+
emptyState.remove();
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
const div = document.createElement('div');
|
| 304 |
+
div.className = 'education-item';
|
| 305 |
+
div.innerHTML = `
|
| 306 |
+
<button type="button" class="btn btn-sm btn-remove" onclick="removeEducation(this)">
|
| 307 |
+
<i class="fas fa-times"></i>
|
| 308 |
+
</button>
|
| 309 |
+
|
| 310 |
+
<div class="row">
|
| 311 |
+
<div class="col-md-6 mb-3">
|
| 312 |
+
<label class="form-label required">Institution</label>
|
| 313 |
+
<input type="text" class="form-control" name="organization[]" required>
|
| 314 |
+
</div>
|
| 315 |
+
<div class="col-md-6 mb-3">
|
| 316 |
+
<label class="form-label required">Degree/Certification</label>
|
| 317 |
+
<input type="text" class="form-control" name="title[]" required>
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
|
| 321 |
+
<div class="row">
|
| 322 |
+
<div class="col-md-6 mb-3">
|
| 323 |
+
<label class="form-label required">Start Date</label>
|
| 324 |
+
<div class="date-inputs">
|
| 325 |
+
<select class="form-control" name="start_month[]" required>
|
| 326 |
+
<option value="">Month</option>
|
| 327 |
+
${generateMonthOptions()}
|
| 328 |
+
</select>
|
| 329 |
+
<select class="form-control" name="start_year[]" required>
|
| 330 |
+
<option value="">Year</option>
|
| 331 |
+
${generateYearOptions()}
|
| 332 |
+
</select>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
<div class="col-md-6 mb-3">
|
| 336 |
+
<label class="form-label">End Date</label>
|
| 337 |
+
<div class="date-inputs">
|
| 338 |
+
<select class="form-control" name="end_month[]">
|
| 339 |
+
<option value="">Month</option>
|
| 340 |
+
${generateMonthOptions()}
|
| 341 |
+
</select>
|
| 342 |
+
<select class="form-control" name="end_year[]">
|
| 343 |
+
<option value="">Year</option>
|
| 344 |
+
${generateYearOptions()}
|
| 345 |
+
</select>
|
| 346 |
+
</div>
|
| 347 |
+
<div class="present-checkbox">
|
| 348 |
+
<div class="form-check">
|
| 349 |
+
<input class="form-check-input" type="checkbox" name="is_present[]" id="present_edu_${educationCount}" onchange="toggleEndDate(this)">
|
| 350 |
+
<label class="form-check-label" for="present_edu_${educationCount}">
|
| 351 |
+
Present
|
| 352 |
+
</label>
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
<div class="mb-3">
|
| 359 |
+
<label class="form-label">Remarks</label>
|
| 360 |
+
<textarea class="form-control" name="remarks[]" rows="3" placeholder="Describe your studies, achievements, or relevant coursework..."></textarea>
|
| 361 |
+
</div>
|
| 362 |
+
`;
|
| 363 |
+
|
| 364 |
+
container.appendChild(div);
|
| 365 |
+
educationCount++;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
function removeEducation(button) {
|
| 369 |
+
const item = button.closest('.education-item');
|
| 370 |
+
item.remove();
|
| 371 |
+
|
| 372 |
+
const container = document.getElementById('educationContainer');
|
| 373 |
+
const remainingItems = container.querySelectorAll('.education-item');
|
| 374 |
+
|
| 375 |
+
if (remainingItems.length === 0) {
|
| 376 |
+
container.innerHTML = `
|
| 377 |
+
<div class="empty-state">
|
| 378 |
+
<i class="fas fa-graduation-cap"></i>
|
| 379 |
+
<h5>No Education Added</h5>
|
| 380 |
+
<p>Click "Add Education" to add your educational background</p>
|
| 381 |
+
</div>
|
| 382 |
+
`;
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
function toggleEndDate(checkbox) {
|
| 387 |
+
const item = checkbox.closest('.education-item');
|
| 388 |
+
const endMonth = item.querySelector('select[name="end_month[]"]');
|
| 389 |
+
const endYear = item.querySelector('select[name="end_year[]"]');
|
| 390 |
+
|
| 391 |
+
if (checkbox.checked) {
|
| 392 |
+
endMonth.disabled = true;
|
| 393 |
+
endYear.disabled = true;
|
| 394 |
+
endMonth.value = '';
|
| 395 |
+
endYear.value = '';
|
| 396 |
+
} else {
|
| 397 |
+
endMonth.disabled = false;
|
| 398 |
+
endYear.disabled = false;
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
function skipToSkills() {
|
| 403 |
+
document.getElementById('educationForm').action = '{{ url_for("create_skills") }}';
|
| 404 |
+
document.getElementById('educationForm').submit();
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
function generateMonthOptions() {
|
| 408 |
+
let options = '';
|
| 409 |
+
for (let i = 1; i <= 12; i++) {
|
| 410 |
+
options += `<option value="${i}">${i}</option>`;
|
| 411 |
+
}
|
| 412 |
+
return options;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
function generateYearOptions() {
|
| 416 |
+
const currentYear = new Date().getFullYear();
|
| 417 |
+
let options = '';
|
| 418 |
+
for (let year = 1980; year <= currentYear + 1; year++) {
|
| 419 |
+
options += `<option value="${year}">${year}</option>`;
|
| 420 |
+
}
|
| 421 |
+
return options;
|
| 422 |
+
}
|
| 423 |
+
</script>
|
| 424 |
+
</body>
|
| 425 |
+
</html>
|
templates/create_introduction.html
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Create Introduction - AI Resume Builder</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
background-color: #f8f9fa;
|
| 12 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
.form-container {
|
| 15 |
+
max-width: 800px;
|
| 16 |
+
margin: 40px auto;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
background: white;
|
| 19 |
+
border-radius: 10px;
|
| 20 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 21 |
+
}
|
| 22 |
+
.section-title {
|
| 23 |
+
color: #2c3e50;
|
| 24 |
+
margin-bottom: 30px;
|
| 25 |
+
text-align: center;
|
| 26 |
+
font-weight: 600;
|
| 27 |
+
}
|
| 28 |
+
.required::after {
|
| 29 |
+
content: " *";
|
| 30 |
+
color: #e74c3c;
|
| 31 |
+
}
|
| 32 |
+
.btn-logout {
|
| 33 |
+
position: absolute;
|
| 34 |
+
top: 20px;
|
| 35 |
+
right: 20px;
|
| 36 |
+
}
|
| 37 |
+
.form-label {
|
| 38 |
+
font-weight: 500;
|
| 39 |
+
margin-bottom: 8px;
|
| 40 |
+
color: #495057;
|
| 41 |
+
}
|
| 42 |
+
.form-control:focus {
|
| 43 |
+
border-color: #4e73df;
|
| 44 |
+
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
|
| 45 |
+
}
|
| 46 |
+
.navigation-buttons {
|
| 47 |
+
display: flex;
|
| 48 |
+
justify-content: space-between;
|
| 49 |
+
margin-top: 30px;
|
| 50 |
+
padding-top: 20px;
|
| 51 |
+
border-top: 1px solid #e9ecef;
|
| 52 |
+
}
|
| 53 |
+
.error-message {
|
| 54 |
+
color: #e74c3c;
|
| 55 |
+
font-size: 0.875rem;
|
| 56 |
+
margin-top: 5px;
|
| 57 |
+
}
|
| 58 |
+
.progress-indicator {
|
| 59 |
+
margin-bottom: 30px;
|
| 60 |
+
text-align: center;
|
| 61 |
+
color: #6c757d;
|
| 62 |
+
font-size: 0.9rem;
|
| 63 |
+
}
|
| 64 |
+
.progress-indicator span {
|
| 65 |
+
display: inline-block;
|
| 66 |
+
margin: 0 10px;
|
| 67 |
+
}
|
| 68 |
+
.progress-indicator span.active {
|
| 69 |
+
color: #4e73df;
|
| 70 |
+
font-weight: 600;
|
| 71 |
+
}
|
| 72 |
+
</style>
|
| 73 |
+
</head>
|
| 74 |
+
<body>
|
| 75 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
| 76 |
+
<div class="container">
|
| 77 |
+
<a class="navbar-brand" href="{{ url_for('profile') }}">AI Resume Builder</a>
|
| 78 |
+
<div class="ms-auto">
|
| 79 |
+
<a href="{{ url_for('logout') }}" class="btn btn-outline-light">
|
| 80 |
+
<i class="fas fa-sign-out-alt"></i> Logout
|
| 81 |
+
</a>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</nav>
|
| 85 |
+
|
| 86 |
+
<div class="container">
|
| 87 |
+
<div class="form-container">
|
| 88 |
+
<div class="progress-indicator">
|
| 89 |
+
<span class="active">Introduction</span>
|
| 90 |
+
<span>→</span>
|
| 91 |
+
<span>Profile Summary</span>
|
| 92 |
+
<span>→</span>
|
| 93 |
+
<span>Work Experience</span>
|
| 94 |
+
<span>→</span>
|
| 95 |
+
<span>Projects</span>
|
| 96 |
+
<span>→</span>
|
| 97 |
+
<span>Education</span>
|
| 98 |
+
<span>→</span>
|
| 99 |
+
<span>Skills</span>
|
| 100 |
+
<span>→</span>
|
| 101 |
+
<span>Achievements</span>
|
| 102 |
+
<span>→</span>
|
| 103 |
+
<span>Preview</span>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<h2 class="section-title">Introduction</h2>
|
| 107 |
+
|
| 108 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 109 |
+
{% if messages %}
|
| 110 |
+
{% for category, message in messages %}
|
| 111 |
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
| 112 |
+
{{ message }}
|
| 113 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 114 |
+
</div>
|
| 115 |
+
{% endfor %}
|
| 116 |
+
{% endif %}
|
| 117 |
+
{% endwith %}
|
| 118 |
+
|
| 119 |
+
<form method="POST" action="{{ url_for('create_introduction') }}" novalidate>
|
| 120 |
+
<div class="row">
|
| 121 |
+
<div class="col-md-6 mb-3">
|
| 122 |
+
<label for="name" class="form-label required">Name</label>
|
| 123 |
+
<input type="text" class="form-control" id="name" name="name" value="{{ form_data.name if form_data }}" required>
|
| 124 |
+
{% if form_errors.name %}
|
| 125 |
+
<div class="error-message">{{ form_errors.name[0] }}</div>
|
| 126 |
+
{% endif %}
|
| 127 |
+
</div>
|
| 128 |
+
<div class="col-md-6 mb-3">
|
| 129 |
+
<label for="email" class="form-label required">Email</label>
|
| 130 |
+
<input type="email" class="form-control" id="email" name="email" value="{{ form_data.email if form_data }}" required>
|
| 131 |
+
{% if form_errors.email %}
|
| 132 |
+
<div class="error-message">{{ form_errors.email[0] }}</div>
|
| 133 |
+
{% endif %}
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div class="row">
|
| 138 |
+
<div class="col-md-6 mb-3">
|
| 139 |
+
<label for="phone" class="form-label required">Phone</label>
|
| 140 |
+
<input type="tel" class="form-control" id="phone" name="phone" value="{{ form_data.phone if form_data }}" required>
|
| 141 |
+
{% if form_errors.phone %}
|
| 142 |
+
<div class="error-message">{{ form_errors.phone[0] }}</div>
|
| 143 |
+
{% endif %}
|
| 144 |
+
</div>
|
| 145 |
+
<div class="col-md-6 mb-3">
|
| 146 |
+
<label for="linkedin" class="form-label">LinkedIn URL</label>
|
| 147 |
+
<input type="url" class="form-control" id="linkedin" name="linkedin" value="{{ form_data.linkedin if form_data }}" placeholder="https://linkedin.com/in/username">
|
| 148 |
+
{% if form_errors.linkedin %}
|
| 149 |
+
<div class="error-message">{{ form_errors.linkedin[0] }}</div>
|
| 150 |
+
{% endif %}
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<div class="row">
|
| 155 |
+
<div class="col-md-6 mb-3">
|
| 156 |
+
<label for="github" class="form-label">GitHub URL</label>
|
| 157 |
+
<input type="url" class="form-control" id="github" name="github" value="{{ form_data.github if form_data }}" placeholder="https://github.com/username">
|
| 158 |
+
{% if form_errors.github %}
|
| 159 |
+
<div class="error-message">{{ form_errors.github[0] }}</div>
|
| 160 |
+
{% endif %}
|
| 161 |
+
</div>
|
| 162 |
+
<div class="col-md-6 mb-3">
|
| 163 |
+
<label for="website" class="form-label">Website</label>
|
| 164 |
+
<input type="url" class="form-control" id="website" name="website" value="{{ form_data.website if form_data }}" placeholder="https://yourwebsite.com">
|
| 165 |
+
{% if form_errors.website %}
|
| 166 |
+
<div class="error-message">{{ form_errors.website[0] }}</div>
|
| 167 |
+
{% endif %}
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div class="navigation-buttons">
|
| 172 |
+
<div></div>
|
| 173 |
+
<div>
|
| 174 |
+
<button type="submit" class="btn btn-primary">
|
| 175 |
+
Next <i class="fas fa-arrow-right"></i>
|
| 176 |
+
</button>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
</form>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 184 |
+
</body>
|
| 185 |
+
</html>
|
templates/create_preview.html
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Preview Profile - AI Resume Builder</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.css">
|
| 10 |
+
<style>
|
| 11 |
+
body {
|
| 12 |
+
background-color: #f8f9fa;
|
| 13 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 14 |
+
}
|
| 15 |
+
.preview-container {
|
| 16 |
+
max-width: 1200px;
|
| 17 |
+
margin: 40px auto;
|
| 18 |
+
padding: 30px;
|
| 19 |
+
background: white;
|
| 20 |
+
border-radius: 10px;
|
| 21 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 22 |
+
}
|
| 23 |
+
.section-title {
|
| 24 |
+
color: #2c3e50;
|
| 25 |
+
margin-bottom: 30px;
|
| 26 |
+
text-align: center;
|
| 27 |
+
font-weight: 600;
|
| 28 |
+
}
|
| 29 |
+
.btn-logout {
|
| 30 |
+
position: absolute;
|
| 31 |
+
top: 20px;
|
| 32 |
+
right: 20px;
|
| 33 |
+
}
|
| 34 |
+
.progress-indicator {
|
| 35 |
+
margin-bottom: 30px;
|
| 36 |
+
text-align: center;
|
| 37 |
+
color: #6c757d;
|
| 38 |
+
font-size: 0.9rem;
|
| 39 |
+
}
|
| 40 |
+
.progress-indicator span {
|
| 41 |
+
display: inline-block;
|
| 42 |
+
margin: 0 10px;
|
| 43 |
+
}
|
| 44 |
+
.progress-indicator span.active {
|
| 45 |
+
color: #4e73df;
|
| 46 |
+
font-weight: 600;
|
| 47 |
+
}
|
| 48 |
+
.preview-layout {
|
| 49 |
+
display: grid;
|
| 50 |
+
grid-template-columns: 1fr 1fr;
|
| 51 |
+
gap: 30px;
|
| 52 |
+
margin-bottom: 30px;
|
| 53 |
+
}
|
| 54 |
+
.reorder-panel {
|
| 55 |
+
background-color: #f8f9fa;
|
| 56 |
+
border: 1px solid #e9ecef;
|
| 57 |
+
border-radius: 8px;
|
| 58 |
+
padding: 20px;
|
| 59 |
+
}
|
| 60 |
+
.reorder-panel h5 {
|
| 61 |
+
color: #495057;
|
| 62 |
+
margin-bottom: 15px;
|
| 63 |
+
}
|
| 64 |
+
.help-text {
|
| 65 |
+
font-size: 0.875rem;
|
| 66 |
+
color: #6c757d;
|
| 67 |
+
margin-bottom: 15px;
|
| 68 |
+
}
|
| 69 |
+
.sortable-list {
|
| 70 |
+
list-style: none;
|
| 71 |
+
padding: 0;
|
| 72 |
+
}
|
| 73 |
+
.sortable-item {
|
| 74 |
+
background: white;
|
| 75 |
+
border: 1px solid #dee2e6;
|
| 76 |
+
border-radius: 6px;
|
| 77 |
+
padding: 12px 15px;
|
| 78 |
+
margin-bottom: 8px;
|
| 79 |
+
cursor: move;
|
| 80 |
+
display: flex;
|
| 81 |
+
align-items: center;
|
| 82 |
+
transition: all 0.3s;
|
| 83 |
+
}
|
| 84 |
+
.sortable-item:hover {
|
| 85 |
+
border-color: #4e73df;
|
| 86 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 87 |
+
}
|
| 88 |
+
.sortable-item.dragging {
|
| 89 |
+
opacity: 0.5;
|
| 90 |
+
}
|
| 91 |
+
.sortable-item.sortable-ghost {
|
| 92 |
+
background: #f8f9fa;
|
| 93 |
+
}
|
| 94 |
+
.sortable-item .drag-handle {
|
| 95 |
+
color: #6c757d;
|
| 96 |
+
margin-right: 10px;
|
| 97 |
+
}
|
| 98 |
+
.sortable-item.fixed {
|
| 99 |
+
background-color: #e9ecef;
|
| 100 |
+
cursor: not-allowed;
|
| 101 |
+
opacity: 0.7;
|
| 102 |
+
}
|
| 103 |
+
.sortable-item.fixed .drag-handle {
|
| 104 |
+
display: none;
|
| 105 |
+
}
|
| 106 |
+
.preview-panel {
|
| 107 |
+
background-color: #ffffff;
|
| 108 |
+
border: 1px solid #e9ecef;
|
| 109 |
+
border-radius: 8px;
|
| 110 |
+
padding: 30px;
|
| 111 |
+
min-height: 600px;
|
| 112 |
+
}
|
| 113 |
+
.preview-section {
|
| 114 |
+
margin-bottom: 25px;
|
| 115 |
+
padding: 15px;
|
| 116 |
+
border-left: 4px solid #4e73df;
|
| 117 |
+
background-color: #f8f9fa;
|
| 118 |
+
}
|
| 119 |
+
.preview-section h5 {
|
| 120 |
+
color: #2c3e50;
|
| 121 |
+
margin-bottom: 15px;
|
| 122 |
+
font-weight: 600;
|
| 123 |
+
}
|
| 124 |
+
.preview-section h6 {
|
| 125 |
+
color: #495057;
|
| 126 |
+
margin-bottom: 10px;
|
| 127 |
+
}
|
| 128 |
+
.contact-info {
|
| 129 |
+
display: flex;
|
| 130 |
+
justify-content: space-between;
|
| 131 |
+
align-items: center;
|
| 132 |
+
margin-bottom: 15px;
|
| 133 |
+
flex-wrap: wrap;
|
| 134 |
+
gap: 10px;
|
| 135 |
+
}
|
| 136 |
+
.contact-item {
|
| 137 |
+
display: flex;
|
| 138 |
+
align-items: center;
|
| 139 |
+
color: #495057;
|
| 140 |
+
font-size: 0.9rem;
|
| 141 |
+
}
|
| 142 |
+
.contact-item i {
|
| 143 |
+
margin-right: 5px;
|
| 144 |
+
width: 16px;
|
| 145 |
+
}
|
| 146 |
+
.experience-item, .project-item, .education-item {
|
| 147 |
+
margin-bottom: 15px;
|
| 148 |
+
padding-bottom: 15px;
|
| 149 |
+
border-bottom: 1px solid #dee2e6;
|
| 150 |
+
}
|
| 151 |
+
.experience-item:last-child, .project-item:last-child, .education-item:last-child {
|
| 152 |
+
border-bottom: none;
|
| 153 |
+
margin-bottom: 0;
|
| 154 |
+
padding-bottom: 0;
|
| 155 |
+
}
|
| 156 |
+
.date-range {
|
| 157 |
+
color: #6c757d;
|
| 158 |
+
font-size: 0.9rem;
|
| 159 |
+
font-weight: 500;
|
| 160 |
+
}
|
| 161 |
+
.skills-grid, .achievements-grid {
|
| 162 |
+
display: flex;
|
| 163 |
+
flex-wrap: wrap;
|
| 164 |
+
gap: 8px;
|
| 165 |
+
}
|
| 166 |
+
.skill-tag, .achievement-tag {
|
| 167 |
+
background-color: #4e73df;
|
| 168 |
+
color: white;
|
| 169 |
+
padding: 4px 12px;
|
| 170 |
+
border-radius: 15px;
|
| 171 |
+
font-size: 0.85rem;
|
| 172 |
+
}
|
| 173 |
+
.achievement-tag {
|
| 174 |
+
background-color: #28a745;
|
| 175 |
+
}
|
| 176 |
+
.action-buttons {
|
| 177 |
+
display: flex;
|
| 178 |
+
justify-content: space-between;
|
| 179 |
+
margin-top: 30px;
|
| 180 |
+
padding-top: 20px;
|
| 181 |
+
border-top: 1px solid #e9ecef;
|
| 182 |
+
}
|
| 183 |
+
.empty-state {
|
| 184 |
+
text-align: center;
|
| 185 |
+
color: #6c757d;
|
| 186 |
+
font-style: italic;
|
| 187 |
+
padding: 20px;
|
| 188 |
+
}
|
| 189 |
+
.ai-generated-badge {
|
| 190 |
+
background-color: #17a2b8;
|
| 191 |
+
color: white;
|
| 192 |
+
padding: 2px 8px;
|
| 193 |
+
border-radius: 4px;
|
| 194 |
+
font-size: 0.75rem;
|
| 195 |
+
margin-left: 10px;
|
| 196 |
+
}
|
| 197 |
+
@media (max-width: 768px) {
|
| 198 |
+
.preview-layout {
|
| 199 |
+
grid-template-columns: 1fr;
|
| 200 |
+
}
|
| 201 |
+
.contact-info {
|
| 202 |
+
flex-direction: column;
|
| 203 |
+
align-items: flex-start;
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
</style>
|
| 207 |
+
</head>
|
| 208 |
+
<body>
|
| 209 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
| 210 |
+
<div class="container">
|
| 211 |
+
<a class="navbar-brand" href="{{ url_for('profile') }}">AI Resume Builder</a>
|
| 212 |
+
<div class="ms-auto">
|
| 213 |
+
<a href="{{ url_for('logout') }}" class="btn btn-outline-light">
|
| 214 |
+
<i class="fas fa-sign-out-alt"></i> Logout
|
| 215 |
+
</a>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
</nav>
|
| 219 |
+
|
| 220 |
+
<div class="container">
|
| 221 |
+
<div class="preview-container">
|
| 222 |
+
<div class="progress-indicator">
|
| 223 |
+
<span>Introduction</span>
|
| 224 |
+
<span>→</span>
|
| 225 |
+
<span>Profile Summary</span>
|
| 226 |
+
<span>→</span>
|
| 227 |
+
<span>Work Experience</span>
|
| 228 |
+
<span>→</span>
|
| 229 |
+
<span>Projects</span>
|
| 230 |
+
<span>→</span>
|
| 231 |
+
<span>Education</span>
|
| 232 |
+
<span>→</span>
|
| 233 |
+
<span>Skills</span>
|
| 234 |
+
<span>→</span>
|
| 235 |
+
<span>Achievements</span>
|
| 236 |
+
<span>→</span>
|
| 237 |
+
<span class="active">Preview</span>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<h2 class="section-title">Preview Your Profile</h2>
|
| 241 |
+
|
| 242 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 243 |
+
{% if messages %}
|
| 244 |
+
{% for category, message in messages %}
|
| 245 |
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
| 246 |
+
{{ message }}
|
| 247 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 248 |
+
</div>
|
| 249 |
+
{% endfor %}
|
| 250 |
+
{% endif %}
|
| 251 |
+
{% endwith %}
|
| 252 |
+
|
| 253 |
+
<div class="preview-layout">
|
| 254 |
+
<div class="reorder-panel">
|
| 255 |
+
<h5><i class="fas fa-arrows-alt"></i> Arrange Your Sections</h5>
|
| 256 |
+
<p class="help-text">
|
| 257 |
+
Drag and drop sections to reorder them. Introduction and Profile Summary are fixed at the top.
|
| 258 |
+
</p>
|
| 259 |
+
<ul id="sortableSections" class="sortable-list">
|
| 260 |
+
<li class="sortable-item fixed" data-section="introduction">
|
| 261 |
+
<i class="fas fa-user"></i> Introduction
|
| 262 |
+
</li>
|
| 263 |
+
<li class="sortable-item fixed" data-section="profile_summary">
|
| 264 |
+
<i class="fas fa-file-alt"></i> Profile Summary
|
| 265 |
+
</li>
|
| 266 |
+
{% if work_experiences %}
|
| 267 |
+
<li class="sortable-item" data-section="work_experience">
|
| 268 |
+
<i class="fas fa-grip-vertical drag-handle"></i>
|
| 269 |
+
<i class="fas fa-briefcase"></i> Work Experience
|
| 270 |
+
</li>
|
| 271 |
+
{% endif %}
|
| 272 |
+
{% if projects %}
|
| 273 |
+
<li class="sortable-item" data-section="projects">
|
| 274 |
+
<i class="fas fa-grip-vertical drag-handle"></i>
|
| 275 |
+
<i class="fas fa-project-diagram"></i> Projects
|
| 276 |
+
</li>
|
| 277 |
+
{% endif %}
|
| 278 |
+
{% if educations %}
|
| 279 |
+
<li class="sortable-item" data-section="education">
|
| 280 |
+
<i class="fas fa-grip-vertical drag-handle"></i>
|
| 281 |
+
<i class="fas fa-graduation-cap"></i> Education
|
| 282 |
+
</li>
|
| 283 |
+
{% endif %}
|
| 284 |
+
{% if skills %}
|
| 285 |
+
<li class="sortable-item" data-section="skills">
|
| 286 |
+
<i class="fas fa-grip-vertical drag-handle"></i>
|
| 287 |
+
<i class="fas fa-tools"></i> Skills
|
| 288 |
+
</li>
|
| 289 |
+
{% endif %}
|
| 290 |
+
{% if achievements %}
|
| 291 |
+
<li class="sortable-item" data-section="achievements">
|
| 292 |
+
<i class="fas fa-grip-vertical drag-handle"></i>
|
| 293 |
+
<i class="fas fa-trophy"></i> Achievements
|
| 294 |
+
</li>
|
| 295 |
+
{% endif %}
|
| 296 |
+
</ul>
|
| 297 |
+
</div>
|
| 298 |
+
|
| 299 |
+
<div class="preview-panel">
|
| 300 |
+
<div id="previewContent">
|
| 301 |
+
<!-- Introduction Section -->
|
| 302 |
+
{% if intro %}
|
| 303 |
+
<div class="preview-section" data-section="introduction">
|
| 304 |
+
<h5><i class="fas fa-user"></i> Introduction</h5>
|
| 305 |
+
<h3 class="text-center mb-3">{{ intro.name }}</h3>
|
| 306 |
+
<div class="contact-info">
|
| 307 |
+
<div class="contact-item">
|
| 308 |
+
<i class="fas fa-envelope"></i> {{ intro.email }}
|
| 309 |
+
</div>
|
| 310 |
+
<div class="contact-item">
|
| 311 |
+
<i class="fas fa-phone"></i> {{ intro.phone }}
|
| 312 |
+
</div>
|
| 313 |
+
{% if intro.linkedin %}
|
| 314 |
+
<div class="contact-item">
|
| 315 |
+
<i class="fab fa-linkedin"></i> LinkedIn
|
| 316 |
+
</div>
|
| 317 |
+
{% endif %}
|
| 318 |
+
{% if intro.github %}
|
| 319 |
+
<div class="contact-item">
|
| 320 |
+
<i class="fab fa-github"></i> GitHub
|
| 321 |
+
</div>
|
| 322 |
+
{% endif %}
|
| 323 |
+
{% if intro.website %}
|
| 324 |
+
<div class="contact-item">
|
| 325 |
+
<i class="fas fa-globe"></i> Website
|
| 326 |
+
</div>
|
| 327 |
+
{% endif %}
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
{% endif %}
|
| 331 |
+
|
| 332 |
+
<!-- Profile Summary Section -->
|
| 333 |
+
{% if summary %}
|
| 334 |
+
<div class="preview-section" data-section="profile_summary">
|
| 335 |
+
<h5>
|
| 336 |
+
<i class="fas fa-file-alt"></i> Profile Summary
|
| 337 |
+
{% if summary.ai_generated %}
|
| 338 |
+
<span class="ai-generated-badge">AI Generated</span>
|
| 339 |
+
{% endif %}
|
| 340 |
+
</h5>
|
| 341 |
+
<p>{{ summary.summary }}</p>
|
| 342 |
+
</div>
|
| 343 |
+
{% endif %}
|
| 344 |
+
|
| 345 |
+
<!-- Dynamic sections will be inserted here based on order -->
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
|
| 350 |
+
<form method="POST" action="{{ url_for('create_preview') }}" id="previewForm">
|
| 351 |
+
<input type="hidden" name="section_order" id="sectionOrderInput" value='[]'>
|
| 352 |
+
|
| 353 |
+
<div class="action-buttons">
|
| 354 |
+
<div>
|
| 355 |
+
<a href="{{ url_for('create_achievements') }}" class="btn btn-outline-secondary">
|
| 356 |
+
<i class="fas fa-arrow-left"></i> Back
|
| 357 |
+
</a>
|
| 358 |
+
</div>
|
| 359 |
+
<div>
|
| 360 |
+
<button type="button" class="btn btn-danger" onclick="confirmClear()">
|
| 361 |
+
<i class="fas fa-trash"></i> Clear All
|
| 362 |
+
</button>
|
| 363 |
+
<button type="submit" name="action" value="submit" class="btn btn-primary">
|
| 364 |
+
<i class="fas fa-check"></i> Submit Profile
|
| 365 |
+
</button>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
</form>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 373 |
+
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
| 374 |
+
<script>
|
| 375 |
+
// Initialize sortable
|
| 376 |
+
const sortable = new Sortable(document.getElementById('sortableSections'), {
|
| 377 |
+
animation: 150,
|
| 378 |
+
ghostClass: 'sortable-ghost',
|
| 379 |
+
dragClass: 'dragging',
|
| 380 |
+
handle: '.drag-handle',
|
| 381 |
+
filter: '.fixed',
|
| 382 |
+
onEnd: function(evt) {
|
| 383 |
+
updateSectionOrder();
|
| 384 |
+
updatePreview();
|
| 385 |
+
}
|
| 386 |
+
});
|
| 387 |
+
|
| 388 |
+
// Get all sections data
|
| 389 |
+
const sectionsData = {
|
| 390 |
+
introduction: {% if intro %}true{% else %}false{% endif %},
|
| 391 |
+
profile_summary: {% if summary %}true{% else %}false{% endif %},
|
| 392 |
+
work_experience: {% if work_experiences %}true{% else %}false{% endif %},
|
| 393 |
+
projects: {% if projects %}true{% else %}false{% endif %},
|
| 394 |
+
education: {% if educations %}true{% else %}false{% endif %},
|
| 395 |
+
skills: {% if skills %}true{% else %}false{% endif %},
|
| 396 |
+
achievements: {% if achievements %}true{% else %}false{% endif %}
|
| 397 |
+
};
|
| 398 |
+
|
| 399 |
+
// Render templates for each section
|
| 400 |
+
const sectionTemplates = {
|
| 401 |
+
work_experience: `
|
| 402 |
+
{% for exp in work_experiences %}
|
| 403 |
+
<div class="experience-item">
|
| 404 |
+
<h6>{{ exp.title }}</h6>
|
| 405 |
+
<p class="text-muted mb-1">{{ exp.organization }}</p>
|
| 406 |
+
<p class="date-range mb-2">
|
| 407 |
+
{{ exp.start_month }}/{{ exp.start_year }} -
|
| 408 |
+
{% if exp.end_year %}{{ exp.end_month }}/{{ exp.end_year }}{% else %}Present{% endif %}
|
| 409 |
+
</p>
|
| 410 |
+
{% if exp.remarks %}
|
| 411 |
+
<p class="mb-0">{{ exp.remarks }}</p>
|
| 412 |
+
{% endif %}
|
| 413 |
+
</div>
|
| 414 |
+
{% endfor %}
|
| 415 |
+
`,
|
| 416 |
+
projects: `
|
| 417 |
+
{% for project in projects %}
|
| 418 |
+
<div class="project-item">
|
| 419 |
+
<h6>{{ project.title }}</h6>
|
| 420 |
+
{% if project.organization %}
|
| 421 |
+
<p class="text-muted mb-1">{{ project.organization }}</p>
|
| 422 |
+
{% endif %}
|
| 423 |
+
<p class="date-range mb-2">
|
| 424 |
+
{{ project.start_month }}/{{ project.start_year }} -
|
| 425 |
+
{% if project.end_year %}{{ project.end_month }}/{{ project.end_year }}{% else %}Present{% endif %}
|
| 426 |
+
</p>
|
| 427 |
+
{% if project.remarks %}
|
| 428 |
+
<p class="mb-0">{{ project.remarks }}</p>
|
| 429 |
+
{% endif %}
|
| 430 |
+
</div>
|
| 431 |
+
{% endfor %}
|
| 432 |
+
`,
|
| 433 |
+
education: `
|
| 434 |
+
{% for edu in educations %}
|
| 435 |
+
<div class="education-item">
|
| 436 |
+
<h6>{{ edu.title }}</h6>
|
| 437 |
+
<p class="text-muted mb-1">{{ edu.organization }}</p>
|
| 438 |
+
<p class="date-range mb-2">
|
| 439 |
+
{{ edu.start_month }}/{{ edu.start_year }} -
|
| 440 |
+
{% if edu.end_year %}{{ edu.end_month }}/{{ edu.end_year }}{% else %}Present{% endif %}
|
| 441 |
+
</p>
|
| 442 |
+
{% if edu.remarks %}
|
| 443 |
+
<p class="mb-0">{{ edu.remarks }}</p>
|
| 444 |
+
{% endif %}
|
| 445 |
+
</div>
|
| 446 |
+
{% endfor %}
|
| 447 |
+
`,
|
| 448 |
+
skills: `
|
| 449 |
+
<div class="skills-grid">
|
| 450 |
+
{% for skill in skills %}
|
| 451 |
+
<span class="skill-tag">{{ skill.skill }}</span>
|
| 452 |
+
{% endfor %}
|
| 453 |
+
</div>
|
| 454 |
+
`,
|
| 455 |
+
achievements: `
|
| 456 |
+
<div class="achievements-grid">
|
| 457 |
+
{% for achievement in achievements %}
|
| 458 |
+
<span class="achievement-tag">{{ achievement.achievement }}</span>
|
| 459 |
+
{% endfor %}
|
| 460 |
+
</div>
|
| 461 |
+
`
|
| 462 |
+
};
|
| 463 |
+
|
| 464 |
+
function updateSectionOrder() {
|
| 465 |
+
const items = document.querySelectorAll('#sortableSections .sortable-item');
|
| 466 |
+
const order = [];
|
| 467 |
+
|
| 468 |
+
items.forEach(item => {
|
| 469 |
+
const section = item.dataset.section;
|
| 470 |
+
if (section && sectionsData[section]) {
|
| 471 |
+
order.push(section);
|
| 472 |
+
}
|
| 473 |
+
});
|
| 474 |
+
|
| 475 |
+
document.getElementById('sectionOrderInput').value = JSON.stringify(order);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
function updatePreview() {
|
| 479 |
+
const items = document.querySelectorAll('#sortableSections .sortable-item');
|
| 480 |
+
const previewContent = document.getElementById('previewContent');
|
| 481 |
+
|
| 482 |
+
// Keep introduction and summary
|
| 483 |
+
const fixedSections = previewContent.querySelectorAll('[data-section="introduction"], [data-section="profile_summary"]');
|
| 484 |
+
const newContent = Array.from(fixedSections);
|
| 485 |
+
|
| 486 |
+
// Add dynamic sections in order
|
| 487 |
+
items.forEach(item => {
|
| 488 |
+
const section = item.dataset.section;
|
| 489 |
+
if (section && section !== 'introduction' && section !== 'profile_summary' && sectionsData[section]) {
|
| 490 |
+
const sectionDiv = document.createElement('div');
|
| 491 |
+
sectionDiv.className = 'preview-section';
|
| 492 |
+
sectionDiv.dataset.section = section;
|
| 493 |
+
|
| 494 |
+
let title = '';
|
| 495 |
+
switch(section) {
|
| 496 |
+
case 'work_experience':
|
| 497 |
+
title = '<i class="fas fa-briefcase"></i> Work Experience';
|
| 498 |
+
break;
|
| 499 |
+
case 'projects':
|
| 500 |
+
title = '<i class="fas fa-project-diagram"></i> Projects';
|
| 501 |
+
break;
|
| 502 |
+
case 'education':
|
| 503 |
+
title = '<i class="fas fa-graduation-cap"></i> Education';
|
| 504 |
+
break;
|
| 505 |
+
case 'skills':
|
| 506 |
+
title = '<i class="fas fa-tools"></i> Skills';
|
| 507 |
+
break;
|
| 508 |
+
case 'achievements':
|
| 509 |
+
title = '<i class="fas fa-trophy"></i> Achievements';
|
| 510 |
+
break;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
sectionDiv.innerHTML = `<h5>${title}</h5>${sectionTemplates[section]}`;
|
| 514 |
+
newContent.push(sectionDiv);
|
| 515 |
+
}
|
| 516 |
+
});
|
| 517 |
+
|
| 518 |
+
previewContent.innerHTML = '';
|
| 519 |
+
newContent.forEach(section => previewContent.appendChild(section));
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
function confirmClear() {
|
| 523 |
+
if (confirm('Are you sure you want to clear all your profile data? This action cannot be undone.')) {
|
| 524 |
+
document.getElementById('previewForm').innerHTML +=
|
| 525 |
+
'<input type="hidden" name="action" value="clear">';
|
| 526 |
+
document.getElementById('previewForm').submit();
|
| 527 |
+
}
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
// Initialize on page load
|
| 531 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 532 |
+
updateSectionOrder();
|
| 533 |
+
updatePreview();
|
| 534 |
+
});
|
| 535 |
+
</script>
|
| 536 |
+
</body>
|
| 537 |
+
</html>
|
templates/create_profile_summary.html
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Profile Summary - AI Resume Builder</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
background-color: #f8f9fa;
|
| 12 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
.form-container {
|
| 15 |
+
max-width: 800px;
|
| 16 |
+
margin: 40px auto;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
background: white;
|
| 19 |
+
border-radius: 10px;
|
| 20 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 21 |
+
}
|
| 22 |
+
.section-title {
|
| 23 |
+
color: #2c3e50;
|
| 24 |
+
margin-bottom: 30px;
|
| 25 |
+
text-align: center;
|
| 26 |
+
font-weight: 600;
|
| 27 |
+
}
|
| 28 |
+
.btn-logout {
|
| 29 |
+
position: absolute;
|
| 30 |
+
top: 20px;
|
| 31 |
+
right: 20px;
|
| 32 |
+
}
|
| 33 |
+
.form-label {
|
| 34 |
+
font-weight: 500;
|
| 35 |
+
margin-bottom: 8px;
|
| 36 |
+
color: #495057;
|
| 37 |
+
}
|
| 38 |
+
.form-control:focus {
|
| 39 |
+
border-color: #4e73df;
|
| 40 |
+
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
|
| 41 |
+
}
|
| 42 |
+
.navigation-buttons {
|
| 43 |
+
display: flex;
|
| 44 |
+
justify-content: space-between;
|
| 45 |
+
margin-top: 30px;
|
| 46 |
+
padding-top: 20px;
|
| 47 |
+
border-top: 1px solid #e9ecef;
|
| 48 |
+
}
|
| 49 |
+
.error-message {
|
| 50 |
+
color: #e74c3c;
|
| 51 |
+
font-size: 0.875rem;
|
| 52 |
+
margin-top: 5px;
|
| 53 |
+
}
|
| 54 |
+
.progress-indicator {
|
| 55 |
+
margin-bottom: 30px;
|
| 56 |
+
text-align: center;
|
| 57 |
+
color: #6c757d;
|
| 58 |
+
font-size: 0.9rem;
|
| 59 |
+
}
|
| 60 |
+
.progress-indicator span {
|
| 61 |
+
display: inline-block;
|
| 62 |
+
margin: 0 10px;
|
| 63 |
+
}
|
| 64 |
+
.progress-indicator span.active {
|
| 65 |
+
color: #4e73df;
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
}
|
| 68 |
+
.ai-generation-section {
|
| 69 |
+
background-color: #f8f9fa;
|
| 70 |
+
padding: 20px;
|
| 71 |
+
border-radius: 8px;
|
| 72 |
+
margin-top: 20px;
|
| 73 |
+
}
|
| 74 |
+
.ai-generation-section h5 {
|
| 75 |
+
color: #4e73df;
|
| 76 |
+
margin-bottom: 15px;
|
| 77 |
+
}
|
| 78 |
+
.btn-generate-ai {
|
| 79 |
+
background: linear-gradient(135deg, #4e73df 0%, #224abe 100%);
|
| 80 |
+
border: none;
|
| 81 |
+
color: white;
|
| 82 |
+
padding: 10px 20px;
|
| 83 |
+
border-radius: 5px;
|
| 84 |
+
transition: all 0.3s;
|
| 85 |
+
}
|
| 86 |
+
.btn-generate-ai:hover {
|
| 87 |
+
background: linear-gradient(135deg, #224abe 0%, #1a2d8a 100%);
|
| 88 |
+
color: white;
|
| 89 |
+
}
|
| 90 |
+
.btn-generate-ai:disabled {
|
| 91 |
+
background: #6c757d;
|
| 92 |
+
cursor: not-allowed;
|
| 93 |
+
}
|
| 94 |
+
.loading-spinner {
|
| 95 |
+
display: none;
|
| 96 |
+
margin-left: 10px;
|
| 97 |
+
}
|
| 98 |
+
.ai-generated-label {
|
| 99 |
+
background-color: #d4edda;
|
| 100 |
+
color: #155724;
|
| 101 |
+
padding: 5px 10px;
|
| 102 |
+
border-radius: 4px;
|
| 103 |
+
font-size: 0.875rem;
|
| 104 |
+
margin-top: 10px;
|
| 105 |
+
display: none;
|
| 106 |
+
}
|
| 107 |
+
textarea.form-control {
|
| 108 |
+
min-height: 150px;
|
| 109 |
+
resize: vertical;
|
| 110 |
+
}
|
| 111 |
+
.help-text {
|
| 112 |
+
font-size: 0.875rem;
|
| 113 |
+
color: #6c757d;
|
| 114 |
+
margin-top: 5px;
|
| 115 |
+
}
|
| 116 |
+
</style>
|
| 117 |
+
</head>
|
| 118 |
+
<body>
|
| 119 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
| 120 |
+
<div class="container">
|
| 121 |
+
<a class="navbar-brand" href="{{ url_for('profile') }}">AI Resume Builder</a>
|
| 122 |
+
<div class="ms-auto">
|
| 123 |
+
<a href="{{ url_for('logout') }}" class="btn btn-outline-light">
|
| 124 |
+
<i class="fas fa-sign-out-alt"></i> Logout
|
| 125 |
+
</a>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</nav>
|
| 129 |
+
|
| 130 |
+
<div class="container">
|
| 131 |
+
<div class="form-container">
|
| 132 |
+
<div class="progress-indicator">
|
| 133 |
+
<span>Introduction</span>
|
| 134 |
+
<span>→</span>
|
| 135 |
+
<span class="active">Profile Summary</span>
|
| 136 |
+
<span>→</span>
|
| 137 |
+
<span>Work Experience</span>
|
| 138 |
+
<span>→</span>
|
| 139 |
+
<span>Projects</span>
|
| 140 |
+
<span>→</span>
|
| 141 |
+
<span>Education</span>
|
| 142 |
+
<span>→</span>
|
| 143 |
+
<span>Skills</span>
|
| 144 |
+
<span>→</span>
|
| 145 |
+
<span>Achievements</span>
|
| 146 |
+
<span>→</span>
|
| 147 |
+
<span>Preview</span>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<h2 class="section-title">Profile Summary</h2>
|
| 151 |
+
|
| 152 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 153 |
+
{% if messages %}
|
| 154 |
+
{% for category, message in messages %}
|
| 155 |
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
| 156 |
+
{{ message }}
|
| 157 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 158 |
+
</div>
|
| 159 |
+
{% endfor %}
|
| 160 |
+
{% endif %}
|
| 161 |
+
{% endwith %}
|
| 162 |
+
|
| 163 |
+
<form method="POST" action="{{ url_for('create_profile_summary') }}" id="profileSummaryForm">
|
| 164 |
+
<div class="mb-3">
|
| 165 |
+
<label for="summary" class="form-label">Profile Summary</label>
|
| 166 |
+
<textarea class="form-control" id="summary" name="summary" rows="6" placeholder="Write a brief summary about yourself...">{{ form_data.summary if form_data }}</textarea>
|
| 167 |
+
<div class="help-text">
|
| 168 |
+
Write a concise summary of your professional background, skills, and career goals (2-4 paragraphs).
|
| 169 |
+
</div>
|
| 170 |
+
{% if form_errors.summary %}
|
| 171 |
+
<div class="error-message">{{ form_errors.summary[0] }}</div>
|
| 172 |
+
{% endif %}
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<div class="ai-generation-section">
|
| 176 |
+
<h5><i class="fas fa-magic"></i> Generate with AI</h5>
|
| 177 |
+
<p class="text-muted mb-3">
|
| 178 |
+
Let AI help you create a compelling profile summary based on your introduction.
|
| 179 |
+
The AI will generate a professional summary that highlights your strengths.
|
| 180 |
+
</p>
|
| 181 |
+
<button type="button" class="btn btn-generate-ai" id="generateAIBtn" onclick="generateAISummary()">
|
| 182 |
+
<i class="fas fa-wand-magic-sparkles"></i> Generate Profile Summary with AI
|
| 183 |
+
<span class="spinner-border spinner-border-sm loading-spinner" id="aiLoading" role="status" aria-hidden="true"></span>
|
| 184 |
+
</button>
|
| 185 |
+
<div class="ai-generated-label" id="aiGeneratedLabel">
|
| 186 |
+
<i class="fas fa-check-circle"></i> AI-generated summary
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
<div class="navigation-buttons">
|
| 191 |
+
<div>
|
| 192 |
+
<a href="{{ url_for('create_introduction') }}" class="btn btn-outline-secondary">
|
| 193 |
+
<i class="fas fa-arrow-left"></i> Back
|
| 194 |
+
</a>
|
| 195 |
+
</div>
|
| 196 |
+
<div>
|
| 197 |
+
<button type="submit" class="btn btn-primary">
|
| 198 |
+
Next <i class="fas fa-arrow-right"></i>
|
| 199 |
+
</button>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
</form>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 207 |
+
<script>
|
| 208 |
+
function generateAISummary() {
|
| 209 |
+
const btn = document.getElementById('generateAIBtn');
|
| 210 |
+
const loading = document.getElementById('aiLoading');
|
| 211 |
+
const aiLabel = document.getElementById('aiGeneratedLabel');
|
| 212 |
+
const summaryTextarea = document.getElementById('summary');
|
| 213 |
+
|
| 214 |
+
btn.disabled = true;
|
| 215 |
+
loading.style.display = 'inline-block';
|
| 216 |
+
aiLabel.style.display = 'none';
|
| 217 |
+
|
| 218 |
+
fetch('/profile/create/generate-summary', {
|
| 219 |
+
method: 'POST',
|
| 220 |
+
headers: {
|
| 221 |
+
'Content-Type': 'application/json',
|
| 222 |
+
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
| 223 |
+
},
|
| 224 |
+
credentials: 'same-origin'
|
| 225 |
+
})
|
| 226 |
+
.then(response => response.json())
|
| 227 |
+
.then(data => {
|
| 228 |
+
if (data.success) {
|
| 229 |
+
summaryTextarea.value = data.summary;
|
| 230 |
+
aiLabel.style.display = 'block';
|
| 231 |
+
|
| 232 |
+
// Show success message
|
| 233 |
+
const alertDiv = document.createElement('div');
|
| 234 |
+
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
|
| 235 |
+
alertDiv.innerHTML = `
|
| 236 |
+
${data.message}
|
| 237 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 238 |
+
`;
|
| 239 |
+
document.querySelector('.ai-generation-section').appendChild(alertDiv);
|
| 240 |
+
} else {
|
| 241 |
+
// Show error message
|
| 242 |
+
const alertDiv = document.createElement('div');
|
| 243 |
+
alertDiv.className = 'alert alert-danger alert-dismissible fade show mt-3';
|
| 244 |
+
alertDiv.innerHTML = `
|
| 245 |
+
${data.message}
|
| 246 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 247 |
+
`;
|
| 248 |
+
document.querySelector('.ai-generation-section').appendChild(alertDiv);
|
| 249 |
+
}
|
| 250 |
+
})
|
| 251 |
+
.catch(error => {
|
| 252 |
+
console.error('Error:', error);
|
| 253 |
+
// Show error message
|
| 254 |
+
const alertDiv = document.createElement('div');
|
| 255 |
+
alertDiv.className = 'alert alert-danger alert-dismissible fade show mt-3';
|
| 256 |
+
alertDiv.innerHTML = `
|
| 257 |
+
Failed to generate summary. Please try again.
|
| 258 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 259 |
+
`;
|
| 260 |
+
document.querySelector('.ai-generation-section').appendChild(alertDiv);
|
| 261 |
+
})
|
| 262 |
+
.finally(() => {
|
| 263 |
+
btn.disabled = false;
|
| 264 |
+
loading.style.display = 'none';
|
| 265 |
+
});
|
| 266 |
+
}
|
| 267 |
+
</script>
|
| 268 |
+
</body>
|
| 269 |
+
</html>
|
templates/create_projects.html
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Projects - AI Resume Builder</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
background-color: #f8f9fa;
|
| 12 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
.form-container {
|
| 15 |
+
max-width: 900px;
|
| 16 |
+
margin: 40px auto;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
background: white;
|
| 19 |
+
border-radius: 10px;
|
| 20 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 21 |
+
}
|
| 22 |
+
.section-title {
|
| 23 |
+
color: #2c3e50;
|
| 24 |
+
margin-bottom: 30px;
|
| 25 |
+
text-align: center;
|
| 26 |
+
font-weight: 600;
|
| 27 |
+
}
|
| 28 |
+
.btn-logout {
|
| 29 |
+
position: absolute;
|
| 30 |
+
top: 20px;
|
| 31 |
+
right: 20px;
|
| 32 |
+
}
|
| 33 |
+
.form-label {
|
| 34 |
+
font-weight: 500;
|
| 35 |
+
margin-bottom: 8px;
|
| 36 |
+
color: #495057;
|
| 37 |
+
}
|
| 38 |
+
.form-control:focus {
|
| 39 |
+
border-color: #4e73df;
|
| 40 |
+
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
|
| 41 |
+
}
|
| 42 |
+
.navigation-buttons {
|
| 43 |
+
display: flex;
|
| 44 |
+
justify-content: space-between;
|
| 45 |
+
margin-top: 30px;
|
| 46 |
+
padding-top: 20px;
|
| 47 |
+
border-top: 1px solid #e9ecef;
|
| 48 |
+
}
|
| 49 |
+
.error-message {
|
| 50 |
+
color: #e74c3c;
|
| 51 |
+
font-size: 0.875rem;
|
| 52 |
+
margin-top: 5px;
|
| 53 |
+
}
|
| 54 |
+
.progress-indicator {
|
| 55 |
+
margin-bottom: 30px;
|
| 56 |
+
text-align: center;
|
| 57 |
+
color: #6c757d;
|
| 58 |
+
font-size: 0.9rem;
|
| 59 |
+
}
|
| 60 |
+
.progress-indicator span {
|
| 61 |
+
display: inline-block;
|
| 62 |
+
margin: 0 10px;
|
| 63 |
+
}
|
| 64 |
+
.progress-indicator span.active {
|
| 65 |
+
color: #4e73df;
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
}
|
| 68 |
+
.project-item {
|
| 69 |
+
background-color: #f8f9fa;
|
| 70 |
+
border: 1px solid #e9ecef;
|
| 71 |
+
border-radius: 8px;
|
| 72 |
+
padding: 20px;
|
| 73 |
+
margin-bottom: 20px;
|
| 74 |
+
position: relative;
|
| 75 |
+
}
|
| 76 |
+
.project-item.has-error {
|
| 77 |
+
border-color: #e74c3c;
|
| 78 |
+
background-color: #fdf2f2;
|
| 79 |
+
}
|
| 80 |
+
.btn-remove {
|
| 81 |
+
position: absolute;
|
| 82 |
+
top: 10px;
|
| 83 |
+
right: 10px;
|
| 84 |
+
color: #e74c3c;
|
| 85 |
+
background: white;
|
| 86 |
+
border: 1px solid #e74c3c;
|
| 87 |
+
padding: 5px 10px;
|
| 88 |
+
border-radius: 5px;
|
| 89 |
+
transition: all 0.3s;
|
| 90 |
+
}
|
| 91 |
+
.btn-remove:hover {
|
| 92 |
+
color: white;
|
| 93 |
+
background-color: #e74c3c;
|
| 94 |
+
}
|
| 95 |
+
.btn-add {
|
| 96 |
+
margin-bottom: 20px;
|
| 97 |
+
background-color: #28a745;
|
| 98 |
+
border: none;
|
| 99 |
+
color: white;
|
| 100 |
+
}
|
| 101 |
+
.btn-add:hover {
|
| 102 |
+
background-color: #218838;
|
| 103 |
+
color: white;
|
| 104 |
+
}
|
| 105 |
+
.present-checkbox {
|
| 106 |
+
margin-top: 10px;
|
| 107 |
+
}
|
| 108 |
+
.date-inputs {
|
| 109 |
+
display: flex;
|
| 110 |
+
gap: 10px;
|
| 111 |
+
}
|
| 112 |
+
.date-inputs .form-control {
|
| 113 |
+
flex: 1;
|
| 114 |
+
}
|
| 115 |
+
.empty-state {
|
| 116 |
+
text-align: center;
|
| 117 |
+
padding: 40px;
|
| 118 |
+
color: #6c757d;
|
| 119 |
+
}
|
| 120 |
+
.empty-state i {
|
| 121 |
+
font-size: 3rem;
|
| 122 |
+
margin-bottom: 20px;
|
| 123 |
+
color: #dee2e6;
|
| 124 |
+
}
|
| 125 |
+
.section-header {
|
| 126 |
+
display: flex;
|
| 127 |
+
justify-content: space-between;
|
| 128 |
+
align-items: center;
|
| 129 |
+
margin-bottom: 20px;
|
| 130 |
+
}
|
| 131 |
+
.optional-badge {
|
| 132 |
+
background-color: #6c757d;
|
| 133 |
+
color: white;
|
| 134 |
+
padding: 2px 8px;
|
| 135 |
+
border-radius: 4px;
|
| 136 |
+
font-size: 0.75rem;
|
| 137 |
+
margin-left: 5px;
|
| 138 |
+
}
|
| 139 |
+
</style>
|
| 140 |
+
</head>
|
| 141 |
+
<body>
|
| 142 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
| 143 |
+
<div class="container">
|
| 144 |
+
<a class="navbar-brand" href="{{ url_for('profile') }}">AI Resume Builder</a>
|
| 145 |
+
<div class="ms-auto">
|
| 146 |
+
<a href="{{ url_for('logout') }}" class="btn btn-outline-light">
|
| 147 |
+
<i class="fas fa-sign-out-alt"></i> Logout
|
| 148 |
+
</a>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
</nav>
|
| 152 |
+
|
| 153 |
+
<div class="container">
|
| 154 |
+
<div class="form-container">
|
| 155 |
+
<div class="progress-indicator">
|
| 156 |
+
<span>Introduction</span>
|
| 157 |
+
<span>→</span>
|
| 158 |
+
<span>Profile Summary</span>
|
| 159 |
+
<span>→</span>
|
| 160 |
+
<span>Work Experience</span>
|
| 161 |
+
<span>→</span>
|
| 162 |
+
<span class="active">Projects</span>
|
| 163 |
+
<span>→</span>
|
| 164 |
+
<span>Education</span>
|
| 165 |
+
<span>→</span>
|
| 166 |
+
<span>Skills</span>
|
| 167 |
+
<span>→</span>
|
| 168 |
+
<span>Achievements</span>
|
| 169 |
+
<span>→</span>
|
| 170 |
+
<span>Preview</span>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div class="section-header">
|
| 174 |
+
<h2 class="section-title mb-0">Projects</h2>
|
| 175 |
+
<button type="button" class="btn btn-add" onclick="addProject()">
|
| 176 |
+
<i class="fas fa-plus"></i> Add Project
|
| 177 |
+
</button>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 181 |
+
{% if messages %}
|
| 182 |
+
{% for category, message in messages %}
|
| 183 |
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
| 184 |
+
{{ message }}
|
| 185 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 186 |
+
</div>
|
| 187 |
+
{% endfor %}
|
| 188 |
+
{% endif %}
|
| 189 |
+
{% endwith %}
|
| 190 |
+
|
| 191 |
+
<form method="POST" action="{{ url_for('create_projects') }}" id="projectsForm">
|
| 192 |
+
<div id="projectsContainer">
|
| 193 |
+
{% if form_data and form_data.projects %}
|
| 194 |
+
{% for project in form_data.projects %}
|
| 195 |
+
<div class="project-item">
|
| 196 |
+
<button type="button" class="btn btn-sm btn-remove" onclick="removeProject(this)">
|
| 197 |
+
<i class="fas fa-times"></i>
|
| 198 |
+
</button>
|
| 199 |
+
|
| 200 |
+
<div class="row">
|
| 201 |
+
<div class="col-md-6 mb-3">
|
| 202 |
+
<label class="form-label">Organization <span class="optional-badge">Optional</span></label>
|
| 203 |
+
<input type="text" class="form-control" name="organization[]" value="{{ project.organization }}">
|
| 204 |
+
</div>
|
| 205 |
+
<div class="col-md-6 mb-3">
|
| 206 |
+
<label class="form-label required">Project Title</label>
|
| 207 |
+
<input type="text" class="form-control" name="title[]" value="{{ project.title }}" required>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
<div class="row">
|
| 212 |
+
<div class="col-md-6 mb-3">
|
| 213 |
+
<label class="form-label required">Start Date</label>
|
| 214 |
+
<div class="date-inputs">
|
| 215 |
+
<select class="form-control" name="start_month[]" required>
|
| 216 |
+
<option value="">Month</option>
|
| 217 |
+
{% for i in range(1, 13) %}
|
| 218 |
+
<option value="{{ i }}" {% if project.start_month == i %}selected{% endif %}>
|
| 219 |
+
{{ i }}
|
| 220 |
+
</option>
|
| 221 |
+
{% endfor %}
|
| 222 |
+
</select>
|
| 223 |
+
<select class="form-control" name="start_year[]" required>
|
| 224 |
+
<option value="">Year</option>
|
| 225 |
+
{% for year in range(1980, current_year + 1) %}
|
| 226 |
+
<option value="{{ year }}" {% if project.start_year == year %}selected{% endif %}>
|
| 227 |
+
{{ year }}
|
| 228 |
+
</option>
|
| 229 |
+
{% endfor %}
|
| 230 |
+
</select>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
<div class="col-md-6 mb-3">
|
| 234 |
+
<label class="form-label">End Date</label>
|
| 235 |
+
<div class="date-inputs">
|
| 236 |
+
<select class="form-control" name="end_month[]" {% if project.is_present %}disabled{% endif %}>
|
| 237 |
+
<option value="">Month</option>
|
| 238 |
+
{% for i in range(1, 13) %}
|
| 239 |
+
<option value="{{ i }}" {% if project.end_month == i %}selected{% endif %}>
|
| 240 |
+
{{ i }}
|
| 241 |
+
</option>
|
| 242 |
+
{% endfor %}
|
| 243 |
+
</select>
|
| 244 |
+
<select class="form-control" name="end_year[]" {% if project.is_present %}disabled{% endif %}>
|
| 245 |
+
<option value="">Year</option>
|
| 246 |
+
{% for year in range(1980, current_year + 1) %}
|
| 247 |
+
<option value="{{ year }}" {% if project.end_year == year %}selected{% endif %}>
|
| 248 |
+
{{ year }}
|
| 249 |
+
</option>
|
| 250 |
+
{% endfor %}
|
| 251 |
+
</select>
|
| 252 |
+
</div>
|
| 253 |
+
<div class="present-checkbox">
|
| 254 |
+
<div class="form-check">
|
| 255 |
+
<input class="form-check-input" type="checkbox" name="is_present[]" id="present_project_{{ loop.index0 }}"
|
| 256 |
+
{% if project.is_present %}checked{% endif %}
|
| 257 |
+
onchange="toggleEndDate(this)">
|
| 258 |
+
<label class="form-check-label" for="present_project_{{ loop.index0 }}">
|
| 259 |
+
Present
|
| 260 |
+
</label>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
<div class="mb-3">
|
| 267 |
+
<label class="form-label">Description</label>
|
| 268 |
+
<textarea class="form-control" name="remarks[]" rows="3" placeholder="Describe the project, your role, technologies used, and outcomes...">{{ project.remarks }}</textarea>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
{% endfor %}
|
| 272 |
+
{% else %}
|
| 273 |
+
<div class="empty-state">
|
| 274 |
+
<i class="fas fa-project-diagram"></i>
|
| 275 |
+
<h5>No Projects Added</h5>
|
| 276 |
+
<p>Click "Add Project" to add your project experience</p>
|
| 277 |
+
</div>
|
| 278 |
+
{% endif %}
|
| 279 |
+
</div>
|
| 280 |
+
|
| 281 |
+
<div class="navigation-buttons">
|
| 282 |
+
<div>
|
| 283 |
+
<a href="{{ url_for('create_work_experience') }}" class="btn btn-outline-secondary">
|
| 284 |
+
<i class="fas fa-arrow-left"></i> Back
|
| 285 |
+
</a>
|
| 286 |
+
<button type="button" class="btn btn-outline-primary" onclick="skipToEducation()">
|
| 287 |
+
Skip <i class="fas fa-forward"></i>
|
| 288 |
+
</button>
|
| 289 |
+
</div>
|
| 290 |
+
<div>
|
| 291 |
+
<button type="submit" class="btn btn-primary">
|
| 292 |
+
Next <i class="fas fa-arrow-right"></i>
|
| 293 |
+
</button>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
</form>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 301 |
+
<script>
|
| 302 |
+
let projectCount = {{ form_data.projects|length if form_data and form_data.projects else 0 }};
|
| 303 |
+
|
| 304 |
+
function addProject() {
|
| 305 |
+
const container = document.getElementById('projectsContainer');
|
| 306 |
+
const emptyState = container.querySelector('.empty-state');
|
| 307 |
+
if (emptyState) {
|
| 308 |
+
emptyState.remove();
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
const div = document.createElement('div');
|
| 312 |
+
div.className = 'project-item';
|
| 313 |
+
div.innerHTML = `
|
| 314 |
+
<button type="button" class="btn btn-sm btn-remove" onclick="removeProject(this)">
|
| 315 |
+
<i class="fas fa-times"></i>
|
| 316 |
+
</button>
|
| 317 |
+
|
| 318 |
+
<div class="row">
|
| 319 |
+
<div class="col-md-6 mb-3">
|
| 320 |
+
<label class="form-label">Organization <span class="optional-badge">Optional</span></label>
|
| 321 |
+
<input type="text" class="form-control" name="organization[]">
|
| 322 |
+
</div>
|
| 323 |
+
<div class="col-md-6 mb-3">
|
| 324 |
+
<label class="form-label required">Project Title</label>
|
| 325 |
+
<input type="text" class="form-control" name="title[]" required>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<div class="row">
|
| 330 |
+
<div class="col-md-6 mb-3">
|
| 331 |
+
<label class="form-label required">Start Date</label>
|
| 332 |
+
<div class="date-inputs">
|
| 333 |
+
<select class="form-control" name="start_month[]" required>
|
| 334 |
+
<option value="">Month</option>
|
| 335 |
+
${generateMonthOptions()}
|
| 336 |
+
</select>
|
| 337 |
+
<select class="form-control" name="start_year[]" required>
|
| 338 |
+
<option value="">Year</option>
|
| 339 |
+
${generateYearOptions()}
|
| 340 |
+
</select>
|
| 341 |
+
</div>
|
| 342 |
+
</div>
|
| 343 |
+
<div class="col-md-6 mb-3">
|
| 344 |
+
<label class="form-label">End Date</label>
|
| 345 |
+
<div class="date-inputs">
|
| 346 |
+
<select class="form-control" name="end_month[]">
|
| 347 |
+
<option value="">Month</option>
|
| 348 |
+
${generateMonthOptions()}
|
| 349 |
+
</select>
|
| 350 |
+
<select class="form-control" name="end_year[]">
|
| 351 |
+
<option value="">Year</option>
|
| 352 |
+
${generateYearOptions()}
|
| 353 |
+
</select>
|
| 354 |
+
</div>
|
| 355 |
+
<div class="present-checkbox">
|
| 356 |
+
<div class="form-check">
|
| 357 |
+
<input class="form-check-input" type="checkbox" name="is_present[]" id="present_project_${projectCount}" onchange="toggleEndDate(this)">
|
| 358 |
+
<label class="form-check-label" for="present_project_${projectCount}">
|
| 359 |
+
Present
|
| 360 |
+
</label>
|
| 361 |
+
</div>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<div class="mb-3">
|
| 367 |
+
<label class="form-label">Description</label>
|
| 368 |
+
<textarea class="form-control" name="remarks[]" rows="3" placeholder="Describe the project, your role, technologies used, and outcomes..."></textarea>
|
| 369 |
+
</div>
|
| 370 |
+
`;
|
| 371 |
+
|
| 372 |
+
container.appendChild(div);
|
| 373 |
+
projectCount++;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
function removeProject(button) {
|
| 377 |
+
const item = button.closest('.project-item');
|
| 378 |
+
item.remove();
|
| 379 |
+
|
| 380 |
+
const container = document.getElementById('projectsContainer');
|
| 381 |
+
const remainingItems = container.querySelectorAll('.project-item');
|
| 382 |
+
|
| 383 |
+
if (remainingItems.length === 0) {
|
| 384 |
+
container.innerHTML = `
|
| 385 |
+
<div class="empty-state">
|
| 386 |
+
<i class="fas fa-project-diagram"></i>
|
| 387 |
+
<h5>No Projects Added</h5>
|
| 388 |
+
<p>Click "Add Project" to add your project experience</p>
|
| 389 |
+
</div>
|
| 390 |
+
`;
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
function toggleEndDate(checkbox) {
|
| 395 |
+
const item = checkbox.closest('.project-item');
|
| 396 |
+
const endMonth = item.querySelector('select[name="end_month[]"]');
|
| 397 |
+
const endYear = item.querySelector('select[name="end_year[]"]');
|
| 398 |
+
|
| 399 |
+
if (checkbox.checked) {
|
| 400 |
+
endMonth.disabled = true;
|
| 401 |
+
endYear.disabled = true;
|
| 402 |
+
endMonth.value = '';
|
| 403 |
+
endYear.value = '';
|
| 404 |
+
} else {
|
| 405 |
+
endMonth.disabled = false;
|
| 406 |
+
endYear.disabled = false;
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
function skipToEducation() {
|
| 411 |
+
document.getElementById('projectsForm').action = '{{ url_for("create_education") }}';
|
| 412 |
+
document.getElementById('projectsForm').submit();
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
function generateMonthOptions() {
|
| 416 |
+
let options = '';
|
| 417 |
+
for (let i = 1; i <= 12; i++) {
|
| 418 |
+
options += `<option value="${i}">${i}</option>`;
|
| 419 |
+
}
|
| 420 |
+
return options;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
function generateYearOptions() {
|
| 424 |
+
const currentYear = new Date().getFullYear();
|
| 425 |
+
let options = '';
|
| 426 |
+
for (let year = 1980; year <= currentYear + 1; year++) {
|
| 427 |
+
options += `<option value="${year}">${year}</option>`;
|
| 428 |
+
}
|
| 429 |
+
return options;
|
| 430 |
+
}
|
| 431 |
+
</script>
|
| 432 |
+
</body>
|
| 433 |
+
</html>
|
templates/create_skills.html
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Skills - AI Resume Builder</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
background-color: #f8f9fa;
|
| 12 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
.form-container {
|
| 15 |
+
max-width: 800px;
|
| 16 |
+
margin: 40px auto;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
background: white;
|
| 19 |
+
border-radius: 10px;
|
| 20 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 21 |
+
}
|
| 22 |
+
.section-title {
|
| 23 |
+
color: #2c3e50;
|
| 24 |
+
margin-bottom: 30px;
|
| 25 |
+
text-align: center;
|
| 26 |
+
font-weight: 600;
|
| 27 |
+
}
|
| 28 |
+
.btn-logout {
|
| 29 |
+
position: absolute;
|
| 30 |
+
top: 20px;
|
| 31 |
+
right: 20px;
|
| 32 |
+
}
|
| 33 |
+
.form-label {
|
| 34 |
+
font-weight: 500;
|
| 35 |
+
margin-bottom: 8px;
|
| 36 |
+
color: #495057;
|
| 37 |
+
}
|
| 38 |
+
.form-control:focus {
|
| 39 |
+
border-color: #4e73df;
|
| 40 |
+
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
|
| 41 |
+
}
|
| 42 |
+
.navigation-buttons {
|
| 43 |
+
display: flex;
|
| 44 |
+
justify-content: space-between;
|
| 45 |
+
margin-top: 30px;
|
| 46 |
+
padding-top: 20px;
|
| 47 |
+
border-top: 1px solid #e9ecef;
|
| 48 |
+
}
|
| 49 |
+
.error-message {
|
| 50 |
+
color: #e74c3c;
|
| 51 |
+
font-size: 0.875rem;
|
| 52 |
+
margin-top: 5px;
|
| 53 |
+
}
|
| 54 |
+
.progress-indicator {
|
| 55 |
+
margin-bottom: 30px;
|
| 56 |
+
text-align: center;
|
| 57 |
+
color: #6c757d;
|
| 58 |
+
font-size: 0.9rem;
|
| 59 |
+
}
|
| 60 |
+
.progress-indicator span {
|
| 61 |
+
display: inline-block;
|
| 62 |
+
margin: 0 10px;
|
| 63 |
+
}
|
| 64 |
+
.progress-indicator span.active {
|
| 65 |
+
color: #4e73df;
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
}
|
| 68 |
+
.skills-display {
|
| 69 |
+
background-color: #f8f9fa;
|
| 70 |
+
border: 1px solid #e9ecef;
|
| 71 |
+
border-radius: 8px;
|
| 72 |
+
padding: 20px;
|
| 73 |
+
margin-top: 20px;
|
| 74 |
+
min-height: 100px;
|
| 75 |
+
}
|
| 76 |
+
.skill-tag {
|
| 77 |
+
display: inline-block;
|
| 78 |
+
background-color: #4e73df;
|
| 79 |
+
color: white;
|
| 80 |
+
padding: 5px 12px;
|
| 81 |
+
margin: 5px;
|
| 82 |
+
border-radius: 20px;
|
| 83 |
+
font-size: 0.9rem;
|
| 84 |
+
position: relative;
|
| 85 |
+
}
|
| 86 |
+
.skill-tag .remove-skill {
|
| 87 |
+
margin-left: 8px;
|
| 88 |
+
cursor: pointer;
|
| 89 |
+
font-size: 0.8rem;
|
| 90 |
+
opacity: 0.7;
|
| 91 |
+
}
|
| 92 |
+
.skill-tag .remove-skill:hover {
|
| 93 |
+
opacity: 1;
|
| 94 |
+
}
|
| 95 |
+
.empty-skills {
|
| 96 |
+
text-align: center;
|
| 97 |
+
color: #6c757d;
|
| 98 |
+
font-style: italic;
|
| 99 |
+
padding: 20px;
|
| 100 |
+
}
|
| 101 |
+
.help-text {
|
| 102 |
+
font-size: 0.875rem;
|
| 103 |
+
color: #6c757d;
|
| 104 |
+
margin-top: 5px;
|
| 105 |
+
}
|
| 106 |
+
.skills-preview-section {
|
| 107 |
+
margin-top: 30px;
|
| 108 |
+
}
|
| 109 |
+
.skills-preview-section h5 {
|
| 110 |
+
color: #495057;
|
| 111 |
+
margin-bottom: 15px;
|
| 112 |
+
}
|
| 113 |
+
</style>
|
| 114 |
+
</head>
|
| 115 |
+
<body>
|
| 116 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
| 117 |
+
<div class="container">
|
| 118 |
+
<a class="navbar-brand" href="{{ url_for('profile') }}">AI Resume Builder</a>
|
| 119 |
+
<div class="ms-auto">
|
| 120 |
+
<a href="{{ url_for('logout') }}" class="btn btn-outline-light">
|
| 121 |
+
<i class="fas fa-sign-out-alt"></i> Logout
|
| 122 |
+
</a>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</nav>
|
| 126 |
+
|
| 127 |
+
<div class="container">
|
| 128 |
+
<div class="form-container">
|
| 129 |
+
<div class="progress-indicator">
|
| 130 |
+
<span>Introduction</span>
|
| 131 |
+
<span>→</span>
|
| 132 |
+
<span>Profile Summary</span>
|
| 133 |
+
<span>→</span>
|
| 134 |
+
<span>Work Experience</span>
|
| 135 |
+
<span>→</span>
|
| 136 |
+
<span>Projects</span>
|
| 137 |
+
<span>→</span>
|
| 138 |
+
<span>Education</span>
|
| 139 |
+
<span>→</span>
|
| 140 |
+
<span class="active">Skills</span>
|
| 141 |
+
<span>→</span>
|
| 142 |
+
<span>Achievements</span>
|
| 143 |
+
<span>→</span>
|
| 144 |
+
<span>Preview</span>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<h2 class="section-title">Skills</h2>
|
| 148 |
+
|
| 149 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 150 |
+
{% if messages %}
|
| 151 |
+
{% for category, message in messages %}
|
| 152 |
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
| 153 |
+
{{ message }}
|
| 154 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 155 |
+
</div>
|
| 156 |
+
{% endfor %}
|
| 157 |
+
{% endif %}
|
| 158 |
+
{% endwith %}
|
| 159 |
+
|
| 160 |
+
<form method="POST" action="{{ url_for('create_skills') }}" id="skillsForm">
|
| 161 |
+
<div class="mb-3">
|
| 162 |
+
<label for="skills" class="form-label">Enter Your Skills</label>
|
| 163 |
+
<textarea class="form-control" id="skills" name="skills" rows="4"
|
| 164 |
+
placeholder="Enter your skills separated by commas. For example: Python, JavaScript, Project Management, Communication, Data Analysis...">{{ form_data.skills if form_data }}</textarea>
|
| 165 |
+
<div class="help-text">
|
| 166 |
+
Enter your technical and soft skills separated by commas. Each skill will be displayed as a separate tag.
|
| 167 |
+
</div>
|
| 168 |
+
{% if form_errors.skills %}
|
| 169 |
+
<div class="error-message">{{ form_errors.skills[0] }}</div>
|
| 170 |
+
{% endif %}
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div class="skills-preview-section">
|
| 174 |
+
<h5><i class="fas fa-eye"></i> Skills Preview</h5>
|
| 175 |
+
<div class="skills-display" id="skillsPreview">
|
| 176 |
+
{% if form_data.skills_preview %}
|
| 177 |
+
{% for skill in form_data.skills_preview %}
|
| 178 |
+
<span class="skill-tag">
|
| 179 |
+
{{ skill }}
|
| 180 |
+
<span class="remove-skill" onclick="removeSkill(this)">×</span>
|
| 181 |
+
</span>
|
| 182 |
+
{% endfor %}
|
| 183 |
+
{% else %}
|
| 184 |
+
<div class="empty-skills">
|
| 185 |
+
Your skills will appear here as you type them above
|
| 186 |
+
</div>
|
| 187 |
+
{% endif %}
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<div class="navigation-buttons">
|
| 192 |
+
<div>
|
| 193 |
+
<a href="{{ url_for('create_education') }}" class="btn btn-outline-secondary">
|
| 194 |
+
<i class="fas fa-arrow-left"></i> Back
|
| 195 |
+
</a>
|
| 196 |
+
<button type="button" class="btn btn-outline-primary" onclick="skipToAchievements()">
|
| 197 |
+
Skip <i class="fas fa-forward"></i>
|
| 198 |
+
</button>
|
| 199 |
+
</div>
|
| 200 |
+
<div>
|
| 201 |
+
<button type="submit" class="btn btn-primary">
|
| 202 |
+
Next <i class="fas fa-arrow-right"></i>
|
| 203 |
+
</button>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</form>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 211 |
+
<script>
|
| 212 |
+
const skillsTextarea = document.getElementById('skills');
|
| 213 |
+
const skillsPreview = document.getElementById('skillsPreview');
|
| 214 |
+
let skills = [];
|
| 215 |
+
|
| 216 |
+
// Initialize skills from form data if exists
|
| 217 |
+
{% if form_data.skills_preview %}
|
| 218 |
+
skills = {{ form_data.skills_preview|tojson }};
|
| 219 |
+
{% endif %}
|
| 220 |
+
|
| 221 |
+
function updateSkillsPreview() {
|
| 222 |
+
const input = skillsTextarea.value;
|
| 223 |
+
const newSkills = input.split(',').map(skill => skill.trim()).filter(skill => skill);
|
| 224 |
+
|
| 225 |
+
// Update preview
|
| 226 |
+
if (newSkills.length === 0) {
|
| 227 |
+
skillsPreview.innerHTML = '<div class="empty-skills">Your skills will appear here as you type them above</div>';
|
| 228 |
+
} else {
|
| 229 |
+
skillsPreview.innerHTML = newSkills.map(skill => `
|
| 230 |
+
<span class="skill-tag">
|
| 231 |
+
${skill}
|
| 232 |
+
<span class="remove-skill" onclick="removeSkill(this)">×</span>
|
| 233 |
+
</span>
|
| 234 |
+
`).join('');
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function removeSkill(element) {
|
| 239 |
+
const skillTag = element.parentElement;
|
| 240 |
+
const skillText = skillTag.textContent.replace('×', '').trim();
|
| 241 |
+
|
| 242 |
+
// Remove from textarea
|
| 243 |
+
const currentSkills = skillsTextarea.value.split(',').map(s => s.trim()).filter(s => s);
|
| 244 |
+
const updatedSkills = currentSkills.filter(skill => skill !== skillText);
|
| 245 |
+
skillsTextarea.value = updatedSkills.join(', ');
|
| 246 |
+
|
| 247 |
+
// Update preview
|
| 248 |
+
updateSkillsPreview();
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
function skipToAchievements() {
|
| 252 |
+
document.getElementById('skillsForm').action = '{{ url_for("create_achievements") }}';
|
| 253 |
+
document.getElementById('skillsForm').submit();
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// Update preview as user types
|
| 257 |
+
skillsTextarea.addEventListener('input', updateSkillsPreview);
|
| 258 |
+
|
| 259 |
+
// Initialize preview on page load
|
| 260 |
+
updateSkillsPreview();
|
| 261 |
+
</script>
|
| 262 |
+
</body>
|
| 263 |
+
</html>
|
templates/create_work_experience.html
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Work Experience - AI Resume Builder</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<style>
|
| 10 |
+
body {
|
| 11 |
+
background-color: #f8f9fa;
|
| 12 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 13 |
+
}
|
| 14 |
+
.form-container {
|
| 15 |
+
max-width: 900px;
|
| 16 |
+
margin: 40px auto;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
background: white;
|
| 19 |
+
border-radius: 10px;
|
| 20 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 21 |
+
}
|
| 22 |
+
.section-title {
|
| 23 |
+
color: #2c3e50;
|
| 24 |
+
margin-bottom: 30px;
|
| 25 |
+
text-align: center;
|
| 26 |
+
font-weight: 600;
|
| 27 |
+
}
|
| 28 |
+
.btn-logout {
|
| 29 |
+
position: absolute;
|
| 30 |
+
top: 20px;
|
| 31 |
+
right: 20px;
|
| 32 |
+
}
|
| 33 |
+
.form-label {
|
| 34 |
+
font-weight: 500;
|
| 35 |
+
margin-bottom: 8px;
|
| 36 |
+
color: #495057;
|
| 37 |
+
}
|
| 38 |
+
.form-control:focus {
|
| 39 |
+
border-color: #4e73df;
|
| 40 |
+
box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25);
|
| 41 |
+
}
|
| 42 |
+
.navigation-buttons {
|
| 43 |
+
display: flex;
|
| 44 |
+
justify-content: space-between;
|
| 45 |
+
margin-top: 30px;
|
| 46 |
+
padding-top: 20px;
|
| 47 |
+
border-top: 1px solid #e9ecef;
|
| 48 |
+
}
|
| 49 |
+
.error-message {
|
| 50 |
+
color: #e74c3c;
|
| 51 |
+
font-size: 0.875rem;
|
| 52 |
+
margin-top: 5px;
|
| 53 |
+
}
|
| 54 |
+
.progress-indicator {
|
| 55 |
+
margin-bottom: 30px;
|
| 56 |
+
text-align: center;
|
| 57 |
+
color: #6c757d;
|
| 58 |
+
font-size: 0.9rem;
|
| 59 |
+
}
|
| 60 |
+
.progress-indicator span {
|
| 61 |
+
display: inline-block;
|
| 62 |
+
margin: 0 10px;
|
| 63 |
+
}
|
| 64 |
+
.progress-indicator span.active {
|
| 65 |
+
color: #4e73df;
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
}
|
| 68 |
+
.work-experience-item {
|
| 69 |
+
background-color: #f8f9fa;
|
| 70 |
+
border: 1px solid #e9ecef;
|
| 71 |
+
border-radius: 8px;
|
| 72 |
+
padding: 20px;
|
| 73 |
+
margin-bottom: 20px;
|
| 74 |
+
position: relative;
|
| 75 |
+
}
|
| 76 |
+
.work-experience-item.has-error {
|
| 77 |
+
border-color: #e74c3c;
|
| 78 |
+
background-color: #fdf2f2;
|
| 79 |
+
}
|
| 80 |
+
.btn-remove {
|
| 81 |
+
position: absolute;
|
| 82 |
+
top: 10px;
|
| 83 |
+
right: 10px;
|
| 84 |
+
color: #e74c3c;
|
| 85 |
+
background: white;
|
| 86 |
+
border: 1px solid #e74c3c;
|
| 87 |
+
padding: 5px 10px;
|
| 88 |
+
border-radius: 5px;
|
| 89 |
+
transition: all 0.3s;
|
| 90 |
+
}
|
| 91 |
+
.btn-remove:hover {
|
| 92 |
+
color: white;
|
| 93 |
+
background-color: #e74c3c;
|
| 94 |
+
}
|
| 95 |
+
.btn-add {
|
| 96 |
+
margin-bottom: 20px;
|
| 97 |
+
background-color: #28a745;
|
| 98 |
+
border: none;
|
| 99 |
+
color: white;
|
| 100 |
+
}
|
| 101 |
+
.btn-add:hover {
|
| 102 |
+
background-color: #218838;
|
| 103 |
+
color: white;
|
| 104 |
+
}
|
| 105 |
+
.present-checkbox {
|
| 106 |
+
margin-top: 10px;
|
| 107 |
+
}
|
| 108 |
+
.date-inputs {
|
| 109 |
+
display: flex;
|
| 110 |
+
gap: 10px;
|
| 111 |
+
}
|
| 112 |
+
.date-inputs .form-control {
|
| 113 |
+
flex: 1;
|
| 114 |
+
}
|
| 115 |
+
.empty-state {
|
| 116 |
+
text-align: center;
|
| 117 |
+
padding: 40px;
|
| 118 |
+
color: #6c757d;
|
| 119 |
+
}
|
| 120 |
+
.empty-state i {
|
| 121 |
+
font-size: 3rem;
|
| 122 |
+
margin-bottom: 20px;
|
| 123 |
+
color: #dee2e6;
|
| 124 |
+
}
|
| 125 |
+
.section-header {
|
| 126 |
+
display: flex;
|
| 127 |
+
justify-content: space-between;
|
| 128 |
+
align-items: center;
|
| 129 |
+
margin-bottom: 20px;
|
| 130 |
+
}
|
| 131 |
+
</style>
|
| 132 |
+
</head>
|
| 133 |
+
<body>
|
| 134 |
+
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
| 135 |
+
<div class="container">
|
| 136 |
+
<a class="navbar-brand" href="{{ url_for('profile') }}">AI Resume Builder</a>
|
| 137 |
+
<div class="ms-auto">
|
| 138 |
+
<a href="{{ url_for('logout') }}" class="btn btn-outline-light">
|
| 139 |
+
<i class="fas fa-sign-out-alt"></i> Logout
|
| 140 |
+
</a>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</nav>
|
| 144 |
+
|
| 145 |
+
<div class="container">
|
| 146 |
+
<div class="form-container">
|
| 147 |
+
<div class="progress-indicator">
|
| 148 |
+
<span>Introduction</span>
|
| 149 |
+
<span>→</span>
|
| 150 |
+
<span>Profile Summary</span>
|
| 151 |
+
<span>→</span>
|
| 152 |
+
<span class="active">Work Experience</span>
|
| 153 |
+
<span>→</span>
|
| 154 |
+
<span>Projects</span>
|
| 155 |
+
<span>→</span>
|
| 156 |
+
<span>Education</span>
|
| 157 |
+
<span>→</span>
|
| 158 |
+
<span>Skills</span>
|
| 159 |
+
<span>→</span>
|
| 160 |
+
<span>Achievements</span>
|
| 161 |
+
<span>→</span>
|
| 162 |
+
<span>Preview</span>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div class="section-header">
|
| 166 |
+
<h2 class="section-title mb-0">Work Experience</h2>
|
| 167 |
+
<button type="button" class="btn btn-add" onclick="addWorkExperience()">
|
| 168 |
+
<i class="fas fa-plus"></i> Add Work Experience
|
| 169 |
+
</button>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 173 |
+
{% if messages %}
|
| 174 |
+
{% for category, message in messages %}
|
| 175 |
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
| 176 |
+
{{ message }}
|
| 177 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 178 |
+
</div>
|
| 179 |
+
{% endfor %}
|
| 180 |
+
{% endif %}
|
| 181 |
+
{% endwith %}
|
| 182 |
+
|
| 183 |
+
<form method="POST" action="{{ url_for('create_work_experience') }}" id="workExperienceForm">
|
| 184 |
+
<div id="workExperienceContainer">
|
| 185 |
+
{% if form_data and form_data.work_experiences %}
|
| 186 |
+
{% for exp in form_data.work_experiences %}
|
| 187 |
+
<div class="work-experience-item">
|
| 188 |
+
<button type="button" class="btn btn-sm btn-remove" onclick="removeWorkExperience(this)">
|
| 189 |
+
<i class="fas fa-times"></i>
|
| 190 |
+
</button>
|
| 191 |
+
|
| 192 |
+
<div class="row">
|
| 193 |
+
<div class="col-md-6 mb-3">
|
| 194 |
+
<label class="form-label required">Organization</label>
|
| 195 |
+
<input type="text" class="form-control" name="organization[]" value="{{ exp.organization }}" required>
|
| 196 |
+
</div>
|
| 197 |
+
<div class="col-md-6 mb-3">
|
| 198 |
+
<label class="form-label required">Title</label>
|
| 199 |
+
<input type="text" class="form-control" name="title[]" value="{{ exp.title }}" required>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div class="row">
|
| 204 |
+
<div class="col-md-6 mb-3">
|
| 205 |
+
<label class="form-label required">Start Date</label>
|
| 206 |
+
<div class="date-inputs">
|
| 207 |
+
<select class="form-control" name="start_month[]" required>
|
| 208 |
+
<option value="">Month</option>
|
| 209 |
+
{% for i in range(1, 13) %}
|
| 210 |
+
<option value="{{ i }}" {% if exp.start_month == i %}selected{% endif %}>
|
| 211 |
+
{{ i }}
|
| 212 |
+
</option>
|
| 213 |
+
{% endfor %}
|
| 214 |
+
</select>
|
| 215 |
+
<select class="form-control" name="start_year[]" required>
|
| 216 |
+
<option value="">Year</option>
|
| 217 |
+
{% for year in range(1980, current_year + 1) %}
|
| 218 |
+
<option value="{{ year }}" {% if exp.start_year == year %}selected{% endif %}>
|
| 219 |
+
{{ year }}
|
| 220 |
+
</option>
|
| 221 |
+
{% endfor %}
|
| 222 |
+
</select>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
<div class="col-md-6 mb-3">
|
| 226 |
+
<label class="form-label">End Date</label>
|
| 227 |
+
<div class="date-inputs">
|
| 228 |
+
<select class="form-control" name="end_month[]" {% if exp.is_present %}disabled{% endif %}>
|
| 229 |
+
<option value="">Month</option>
|
| 230 |
+
{% for i in range(1, 13) %}
|
| 231 |
+
<option value="{{ i }}" {% if exp.end_month == i %}selected{% endif %}>
|
| 232 |
+
{{ i }}
|
| 233 |
+
</option>
|
| 234 |
+
{% endfor %}
|
| 235 |
+
</select>
|
| 236 |
+
<select class="form-control" name="end_year[]" {% if exp.is_present %}disabled{% endif %}>
|
| 237 |
+
<option value="">Year</option>
|
| 238 |
+
{% for year in range(1980, current_year + 1) %}
|
| 239 |
+
<option value="{{ year }}" {% if exp.end_year == year %}selected{% endif %}>
|
| 240 |
+
{{ year }}
|
| 241 |
+
</option>
|
| 242 |
+
{% endfor %}
|
| 243 |
+
</select>
|
| 244 |
+
</div>
|
| 245 |
+
<div class="present-checkbox">
|
| 246 |
+
<div class="form-check">
|
| 247 |
+
<input class="form-check-input" type="checkbox" name="is_present[]" id="present_{{ loop.index0 }}"
|
| 248 |
+
{% if exp.is_present %}checked{% endif %}
|
| 249 |
+
onchange="toggleEndDate(this)">
|
| 250 |
+
<label class="form-check-label" for="present_{{ loop.index0 }}">
|
| 251 |
+
Present
|
| 252 |
+
</label>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
<div class="mb-3">
|
| 259 |
+
<label class="form-label">Remarks</label>
|
| 260 |
+
<textarea class="form-control" name="remarks[]" rows="3" placeholder="Describe your responsibilities and achievements...">{{ exp.remarks }}</textarea>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
{% endfor %}
|
| 264 |
+
{% else %}
|
| 265 |
+
<div class="empty-state">
|
| 266 |
+
<i class="fas fa-briefcase"></i>
|
| 267 |
+
<h5>No Work Experience Added</h5>
|
| 268 |
+
<p>Click "Add Work Experience" to add your work history</p>
|
| 269 |
+
</div>
|
| 270 |
+
{% endif %}
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
<div class="navigation-buttons">
|
| 274 |
+
<div>
|
| 275 |
+
<a href="{{ url_for('create_profile_summary') }}" class="btn btn-outline-secondary">
|
| 276 |
+
<i class="fas fa-arrow-left"></i> Back
|
| 277 |
+
</a>
|
| 278 |
+
<button type="button" class="btn btn-outline-primary" onclick="skipToProjects()">
|
| 279 |
+
Skip <i class="fas fa-forward"></i>
|
| 280 |
+
</button>
|
| 281 |
+
</div>
|
| 282 |
+
<div>
|
| 283 |
+
<button type="submit" class="btn btn-primary">
|
| 284 |
+
Next <i class="fas fa-arrow-right"></i>
|
| 285 |
+
</button>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
</form>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 293 |
+
<script>
|
| 294 |
+
let experienceCount = {{ form_data.work_experiences|length if form_data and form_data.work_experiences else 0 }};
|
| 295 |
+
|
| 296 |
+
function addWorkExperience() {
|
| 297 |
+
const container = document.getElementById('workExperienceContainer');
|
| 298 |
+
const emptyState = container.querySelector('.empty-state');
|
| 299 |
+
if (emptyState) {
|
| 300 |
+
emptyState.remove();
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
const div = document.createElement('div');
|
| 304 |
+
div.className = 'work-experience-item';
|
| 305 |
+
div.innerHTML = `
|
| 306 |
+
<button type="button" class="btn btn-sm btn-remove" onclick="removeWorkExperience(this)">
|
| 307 |
+
<i class="fas fa-times"></i>
|
| 308 |
+
</button>
|
| 309 |
+
|
| 310 |
+
<div class="row">
|
| 311 |
+
<div class="col-md-6 mb-3">
|
| 312 |
+
<label class="form-label required">Organization</label>
|
| 313 |
+
<input type="text" class="form-control" name="organization[]" required>
|
| 314 |
+
</div>
|
| 315 |
+
<div class="col-md-6 mb-3">
|
| 316 |
+
<label class="form-label required">Title</label>
|
| 317 |
+
<input type="text" class="form-control" name="title[]" required>
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
|
| 321 |
+
<div class="row">
|
| 322 |
+
<div class="col-md-6 mb-3">
|
| 323 |
+
<label class="form-label required">Start Date</label>
|
| 324 |
+
<div class="date-inputs">
|
| 325 |
+
<select class="form-control" name="start_month[]" required>
|
| 326 |
+
<option value="">Month</option>
|
| 327 |
+
${generateMonthOptions()}
|
| 328 |
+
</select>
|
| 329 |
+
<select class="form-control" name="start_year[]" required>
|
| 330 |
+
<option value="">Year</option>
|
| 331 |
+
${generateYearOptions()}
|
| 332 |
+
</select>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
<div class="col-md-6 mb-3">
|
| 336 |
+
<label class="form-label">End Date</label>
|
| 337 |
+
<div class="date-inputs">
|
| 338 |
+
<select class="form-control" name="end_month[]">
|
| 339 |
+
<option value="">Month</option>
|
| 340 |
+
${generateMonthOptions()}
|
| 341 |
+
</select>
|
| 342 |
+
<select class="form-control" name="end_year[]">
|
| 343 |
+
<option value="">Year</option>
|
| 344 |
+
${generateYearOptions()}
|
| 345 |
+
</select>
|
| 346 |
+
</div>
|
| 347 |
+
<div class="present-checkbox">
|
| 348 |
+
<div class="form-check">
|
| 349 |
+
<input class="form-check-input" type="checkbox" name="is_present[]" id="present_${experienceCount}" onchange="toggleEndDate(this)">
|
| 350 |
+
<label class="form-check-label" for="present_${experienceCount}">
|
| 351 |
+
Present
|
| 352 |
+
</label>
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
<div class="mb-3">
|
| 359 |
+
<label class="form-label">Remarks</label>
|
| 360 |
+
<textarea class="form-control" name="remarks[]" rows="3" placeholder="Describe your responsibilities and achievements..."></textarea>
|
| 361 |
+
</div>
|
| 362 |
+
`;
|
| 363 |
+
|
| 364 |
+
container.appendChild(div);
|
| 365 |
+
experienceCount++;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
function removeWorkExperience(button) {
|
| 369 |
+
const item = button.closest('.work-experience-item');
|
| 370 |
+
item.remove();
|
| 371 |
+
|
| 372 |
+
const container = document.getElementById('workExperienceContainer');
|
| 373 |
+
const remainingItems = container.querySelectorAll('.work-experience-item');
|
| 374 |
+
|
| 375 |
+
if (remainingItems.length === 0) {
|
| 376 |
+
container.innerHTML = `
|
| 377 |
+
<div class="empty-state">
|
| 378 |
+
<i class="fas fa-briefcase"></i>
|
| 379 |
+
<h5>No Work Experience Added</h5>
|
| 380 |
+
<p>Click "Add Work Experience" to add your work history</p>
|
| 381 |
+
</div>
|
| 382 |
+
`;
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
function toggleEndDate(checkbox) {
|
| 387 |
+
const item = checkbox.closest('.work-experience-item');
|
| 388 |
+
const endMonth = item.querySelector('select[name="end_month[]"]');
|
| 389 |
+
const endYear = item.querySelector('select[name="end_year[]"]');
|
| 390 |
+
|
| 391 |
+
if (checkbox.checked) {
|
| 392 |
+
endMonth.disabled = true;
|
| 393 |
+
endYear.disabled = true;
|
| 394 |
+
endMonth.value = '';
|
| 395 |
+
endYear.value = '';
|
| 396 |
+
} else {
|
| 397 |
+
endMonth.disabled = false;
|
| 398 |
+
endYear.disabled = false;
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
function skipToProjects() {
|
| 403 |
+
document.getElementById('workExperienceForm').action = '{{ url_for("create_projects") }}';
|
| 404 |
+
document.getElementById('workExperienceForm').submit();
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
function generateMonthOptions() {
|
| 408 |
+
let options = '';
|
| 409 |
+
for (let i = 1; i <= 12; i++) {
|
| 410 |
+
options += `<option value="${i}">${i}</option>`;
|
| 411 |
+
}
|
| 412 |
+
return options;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
function generateYearOptions() {
|
| 416 |
+
const currentYear = new Date().getFullYear();
|
| 417 |
+
let options = '';
|
| 418 |
+
for (let year = 1980; year <= currentYear + 1; year++) {
|
| 419 |
+
options += `<option value="${year}">${year}</option>`;
|
| 420 |
+
}
|
| 421 |
+
return options;
|
| 422 |
+
}
|
| 423 |
+
</script>
|
| 424 |
+
</body>
|
| 425 |
+
</html>
|
templates/forgot_password.html
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Forgot Password - AI Resume Builder{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
| 7 |
+
<div class="max-w-md w-full space-y-8">
|
| 8 |
+
<div class="text-center">
|
| 9 |
+
<h2 class="text-2xl font-semibold text-gray-800 mb-6">
|
| 10 |
+
Forgot your password?
|
| 11 |
+
</h2>
|
| 12 |
+
<p class="text-gray-600">
|
| 13 |
+
Enter your email address and we'll send you a link to reset your password.
|
| 14 |
+
</p>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<form method="POST" action="{{ url_for('forgot_password') }}" class="mt-8 space-y-6">
|
| 18 |
+
|
| 19 |
+
<div>
|
| 20 |
+
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
| 21 |
+
Email address
|
| 22 |
+
</label>
|
| 23 |
+
<input
|
| 24 |
+
id="email"
|
| 25 |
+
name="email"
|
| 26 |
+
type="email"
|
| 27 |
+
autocomplete="email"
|
| 28 |
+
required
|
| 29 |
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
| 30 |
+
placeholder="Enter your email"
|
| 31 |
+
value="{{ request.form.email or '' }}"
|
| 32 |
+
/>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div>
|
| 36 |
+
<button
|
| 37 |
+
type="submit"
|
| 38 |
+
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
| 39 |
+
>
|
| 40 |
+
Send Reset Link
|
| 41 |
+
</button>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div class="text-center">
|
| 45 |
+
<p class="text-sm text-gray-600">
|
| 46 |
+
Remember your password?
|
| 47 |
+
<a href="{{ url_for('signin') }}" class="font-medium text-blue-600 hover:text-blue-500">
|
| 48 |
+
Sign in
|
| 49 |
+
</a>
|
| 50 |
+
</p>
|
| 51 |
+
</div>
|
| 52 |
+
</form>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
{% endblock %}
|
templates/profile.html
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Profile - AI Resume Builder{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="min-h-screen bg-gray-50">
|
| 7 |
+
<!-- Header -->
|
| 8 |
+
<header class="bg-white shadow-sm border-b border-gray-200">
|
| 9 |
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 10 |
+
<div class="flex justify-between items-center py-4">
|
| 11 |
+
<div class="flex items-center">
|
| 12 |
+
<h1 class="text-2xl font-bold text-gray-900">My Profile</h1>
|
| 13 |
+
</div>
|
| 14 |
+
<div class="flex items-center space-x-4">
|
| 15 |
+
{% if current_user.is_admin %}
|
| 16 |
+
<div class="relative">
|
| 17 |
+
<button onclick="toggleDropdown()" class="text-sm text-gray-600 hover:text-gray-800 flex items-center space-x-1">
|
| 18 |
+
<span>Welcome, {{ current_user.name }}</span>
|
| 19 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 20 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
| 21 |
+
</svg>
|
| 22 |
+
</button>
|
| 23 |
+
<div id="adminDropdown" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
|
| 24 |
+
<a href="{{ url_for('admin_panel') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
| 25 |
+
<i class="fas fa-shield-alt mr-2"></i>Admin Panel
|
| 26 |
+
</a>
|
| 27 |
+
<div class="border-t border-gray-100"></div>
|
| 28 |
+
<a href="{{ url_for('logout') }}" class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100">
|
| 29 |
+
Logout
|
| 30 |
+
</a>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
{% else %}
|
| 34 |
+
<span class="text-sm text-gray-600">Welcome, {{ current_user.name }}</span>
|
| 35 |
+
<a href="{{ url_for('logout') }}" class="text-sm text-red-600 hover:text-red-500">
|
| 36 |
+
Logout
|
| 37 |
+
</a>
|
| 38 |
+
{% endif %}
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</header>
|
| 43 |
+
|
| 44 |
+
<!-- Main Content -->
|
| 45 |
+
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
| 46 |
+
<div class="px-4 py-6 sm:px-0">
|
| 47 |
+
{% if has_profile %}
|
| 48 |
+
<!-- Profile Display -->
|
| 49 |
+
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
| 50 |
+
<div class="flex justify-between items-center mb-6">
|
| 51 |
+
<h2 class="text-xl font-semibold text-gray-800">Your Professional Profile</h2>
|
| 52 |
+
<div class="flex space-x-3">
|
| 53 |
+
<button onclick="showResumeOptions()" class="bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded-lg transition-colors">
|
| 54 |
+
<i class="fas fa-download mr-2"></i>Generate Resume
|
| 55 |
+
</button>
|
| 56 |
+
<a href="{{ url_for('create_introduction') }}" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors">
|
| 57 |
+
<i class="fas fa-edit mr-2"></i>Edit Profile
|
| 58 |
+
</a>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<!-- Display sections based on user's order -->
|
| 63 |
+
{% for section_name in section_order %}
|
| 64 |
+
{% if section_name == 'introduction' and intro %}
|
| 65 |
+
<div class="mb-8">
|
| 66 |
+
<div class="text-center mb-6">
|
| 67 |
+
<h3 class="text-2xl font-bold text-gray-900 mb-2">{{ intro.name }}</h3>
|
| 68 |
+
<div class="flex justify-center flex-wrap gap-4 text-sm text-gray-600">
|
| 69 |
+
<span><i class="fas fa-envelope mr-1"></i> {{ intro.email }}</span>
|
| 70 |
+
<span><i class="fas fa-phone mr-1"></i> {{ intro.phone }}</span>
|
| 71 |
+
{% if intro.linkedin %}
|
| 72 |
+
<span><i class="fab fa-linkedin mr-1"></i> LinkedIn</span>
|
| 73 |
+
{% endif %}
|
| 74 |
+
{% if intro.github %}
|
| 75 |
+
<span><i class="fab fa-github mr-1"></i> GitHub</span>
|
| 76 |
+
{% endif %}
|
| 77 |
+
{% if intro.website %}
|
| 78 |
+
<span><i class="fas fa-globe mr-1"></i> Website</span>
|
| 79 |
+
{% endif %}
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
{% endif %}
|
| 84 |
+
|
| 85 |
+
{% if section_name == 'profile_summary' and summary %}
|
| 86 |
+
<div class="mb-8">
|
| 87 |
+
<h4 class="text-lg font-semibold text-gray-800 mb-3">Professional Summary</h4>
|
| 88 |
+
<p class="text-gray-700 leading-relaxed">{{ summary.summary }}</p>
|
| 89 |
+
{% if summary.ai_generated %}
|
| 90 |
+
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded mt-2">AI Generated</span>
|
| 91 |
+
{% endif %}
|
| 92 |
+
</div>
|
| 93 |
+
{% endif %}
|
| 94 |
+
|
| 95 |
+
{% if section_name == 'work_experience' and work_experiences %}
|
| 96 |
+
<div class="mb-8">
|
| 97 |
+
<h4 class="text-lg font-semibold text-gray-800 mb-4">Work Experience</h4>
|
| 98 |
+
{% for exp in work_experiences %}
|
| 99 |
+
<div class="mb-4 pb-4 border-b border-gray-200 last:border-0">
|
| 100 |
+
<h5 class="font-medium text-gray-900">{{ exp.title }}</h5>
|
| 101 |
+
<p class="text-gray-600 text-sm">{{ exp.organization }}</p>
|
| 102 |
+
<p class="text-gray-500 text-sm mb-2">
|
| 103 |
+
{{ exp.start_month }}/{{ exp.start_year }} -
|
| 104 |
+
{% if exp.end_year %}{{ exp.end_month }}/{{ exp.end_year }}{% else %}Present{% endif %}
|
| 105 |
+
</p>
|
| 106 |
+
{% if exp.remarks %}
|
| 107 |
+
<p class="text-gray-700 text-sm">{{ exp.remarks }}</p>
|
| 108 |
+
{% endif %}
|
| 109 |
+
</div>
|
| 110 |
+
{% endfor %}
|
| 111 |
+
</div>
|
| 112 |
+
{% endif %}
|
| 113 |
+
|
| 114 |
+
{% if section_name == 'projects' and projects %}
|
| 115 |
+
<div class="mb-8">
|
| 116 |
+
<h4 class="text-lg font-semibold text-gray-800 mb-4">Projects</h4>
|
| 117 |
+
{% for project in projects %}
|
| 118 |
+
<div class="mb-4 pb-4 border-b border-gray-200 last:border-0">
|
| 119 |
+
<h5 class="font-medium text-gray-900">{{ project.title }}</h5>
|
| 120 |
+
{% if project.organization %}
|
| 121 |
+
<p class="text-gray-600 text-sm">{{ project.organization }}</p>
|
| 122 |
+
{% endif %}
|
| 123 |
+
<p class="text-gray-500 text-sm mb-2">
|
| 124 |
+
{{ project.start_month }}/{{ project.start_year }} -
|
| 125 |
+
{% if project.end_year %}{{ project.end_month }}/{{ project.end_year }}{% else %}Present{% endif %}
|
| 126 |
+
</p>
|
| 127 |
+
{% if project.remarks %}
|
| 128 |
+
<p class="text-gray-700 text-sm">{{ project.remarks }}</p>
|
| 129 |
+
{% endif %}
|
| 130 |
+
</div>
|
| 131 |
+
{% endfor %}
|
| 132 |
+
</div>
|
| 133 |
+
{% endif %}
|
| 134 |
+
|
| 135 |
+
{% if section_name == 'education' and educations %}
|
| 136 |
+
<div class="mb-8">
|
| 137 |
+
<h4 class="text-lg font-semibold text-gray-800 mb-4">Education</h4>
|
| 138 |
+
{% for edu in educations %}
|
| 139 |
+
<div class="mb-4 pb-4 border-b border-gray-200 last:border-0">
|
| 140 |
+
<h5 class="font-medium text-gray-900">{{ edu.title }}</h5>
|
| 141 |
+
<p class="text-gray-600 text-sm">{{ edu.organization }}</p>
|
| 142 |
+
<p class="text-gray-500 text-sm mb-2">
|
| 143 |
+
{{ edu.start_month }}/{{ edu.start_year }} -
|
| 144 |
+
{% if edu.end_year %}{{ edu.end_month }}/{{ edu.end_year }}{% else %}Present{% endif %}
|
| 145 |
+
</p>
|
| 146 |
+
{% if edu.remarks %}
|
| 147 |
+
<p class="text-gray-700 text-sm">{{ edu.remarks }}</p>
|
| 148 |
+
{% endif %}
|
| 149 |
+
</div>
|
| 150 |
+
{% endfor %}
|
| 151 |
+
</div>
|
| 152 |
+
{% endif %}
|
| 153 |
+
|
| 154 |
+
{% if section_name == 'skills' and skills %}
|
| 155 |
+
<div class="mb-8">
|
| 156 |
+
<h4 class="text-lg font-semibold text-gray-800 mb-3">Skills</h4>
|
| 157 |
+
<div class="flex flex-wrap gap-2">
|
| 158 |
+
{% for skill in skills %}
|
| 159 |
+
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm">{{ skill.skill }}</span>
|
| 160 |
+
{% endfor %}
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
{% endif %}
|
| 164 |
+
|
| 165 |
+
{% if section_name == 'achievements' and achievements %}
|
| 166 |
+
<div class="mb-8">
|
| 167 |
+
<h4 class="text-lg font-semibold text-gray-800 mb-3">Achievements</h4>
|
| 168 |
+
<div class="flex flex-wrap gap-2">
|
| 169 |
+
{% for achievement in achievements %}
|
| 170 |
+
<span class="bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm">{{ achievement.achievement }}</span>
|
| 171 |
+
{% endfor %}
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
{% endif %}
|
| 175 |
+
{% endfor %}
|
| 176 |
+
</div>
|
| 177 |
+
{% else %}
|
| 178 |
+
<!-- Empty State -->
|
| 179 |
+
<div class="bg-white rounded-lg shadow p-6">
|
| 180 |
+
<div class="text-center">
|
| 181 |
+
<h2 class="text-xl font-semibold text-gray-800 mb-4">Welcome to AI Resume Builder!</h2>
|
| 182 |
+
<p class="text-gray-600 mb-6">
|
| 183 |
+
Your profile is currently empty. Click the button below to start creating your professional resume.
|
| 184 |
+
</p>
|
| 185 |
+
<a href="{{ url_for('create_introduction') }}" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors inline-block">
|
| 186 |
+
Create Your Profile
|
| 187 |
+
</a>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
{% endif %}
|
| 191 |
+
</div>
|
| 192 |
+
</main>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<!-- Resume Options Modal -->
|
| 196 |
+
<div id="resumeModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden items-center justify-center z-50">
|
| 197 |
+
<div class="bg-white rounded-lg p-6 m-4 max-w-md w-full">
|
| 198 |
+
<h3 class="text-lg font-semibold text-gray-900 mb-4">Choose Resume Format</h3>
|
| 199 |
+
<div class="space-y-3">
|
| 200 |
+
<button onclick="generateResume('pdf-standard')" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors">
|
| 201 |
+
<i class="fas fa-file-pdf mr-2"></i>Generate PDF (Standard)
|
| 202 |
+
</button>
|
| 203 |
+
<button onclick="generateResume('pdf-modern')" class="w-full bg-purple-600 hover:bg-purple-700 text-white font-medium py-2 px-4 rounded-lg transition-colors">
|
| 204 |
+
<i class="fas fa-file-pdf mr-2"></i>Generate PDF (Modern)
|
| 205 |
+
</button>
|
| 206 |
+
<button onclick="generateResume('word')" class="w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded-lg transition-colors">
|
| 207 |
+
<i class="fas fa-file-word mr-2"></i>Generate Word Document
|
| 208 |
+
</button>
|
| 209 |
+
</div>
|
| 210 |
+
<button onclick="hideResumeOptions()" class="mt-4 w-full bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors">
|
| 211 |
+
Cancel
|
| 212 |
+
</button>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
<script>
|
| 217 |
+
function showResumeOptions() {
|
| 218 |
+
document.getElementById('resumeModal').classList.remove('hidden');
|
| 219 |
+
document.getElementById('resumeModal').classList.add('flex');
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function hideResumeOptions() {
|
| 223 |
+
document.getElementById('resumeModal').classList.add('hidden');
|
| 224 |
+
document.getElementById('resumeModal').classList.remove('flex');
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function generateResume(format) {
|
| 228 |
+
// Show loading state
|
| 229 |
+
const modal = document.getElementById('resumeModal');
|
| 230 |
+
const buttons = modal.querySelectorAll('button');
|
| 231 |
+
buttons.forEach(btn => btn.disabled = true);
|
| 232 |
+
|
| 233 |
+
// Create and show loading message
|
| 234 |
+
const loadingMsg = document.createElement('div');
|
| 235 |
+
loadingMsg.className = 'text-center mt-4 text-gray-600';
|
| 236 |
+
loadingMsg.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Generating your resume...';
|
| 237 |
+
modal.querySelector('.bg-white').appendChild(loadingMsg);
|
| 238 |
+
|
| 239 |
+
// Fetch the resume
|
| 240 |
+
fetch(`/profile/generate-resume/${format}`, {
|
| 241 |
+
method: 'GET',
|
| 242 |
+
headers: {
|
| 243 |
+
'X-Requested-With': 'XMLHttpRequest'
|
| 244 |
+
}
|
| 245 |
+
})
|
| 246 |
+
.then(response => {
|
| 247 |
+
if (!response.ok) {
|
| 248 |
+
throw new Error('Failed to generate resume');
|
| 249 |
+
}
|
| 250 |
+
return response.blob();
|
| 251 |
+
})
|
| 252 |
+
.then(blob => {
|
| 253 |
+
// Create download link
|
| 254 |
+
const url = window.URL.createObjectURL(blob);
|
| 255 |
+
const a = document.createElement('a');
|
| 256 |
+
a.href = url;
|
| 257 |
+
|
| 258 |
+
// Set filename based on format
|
| 259 |
+
const username = '{{ current_user.name }}'.replace(/\s+/g, '_');
|
| 260 |
+
if (format === 'pdf-standard') {
|
| 261 |
+
a.download = `${username}_resume.pdf`;
|
| 262 |
+
} else if (format === 'pdf-modern') {
|
| 263 |
+
a.download = `${username}_resume_modern.pdf`;
|
| 264 |
+
} else if (format === 'word') {
|
| 265 |
+
a.download = `${username}_resume.docx`;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
document.body.appendChild(a);
|
| 269 |
+
a.click();
|
| 270 |
+
window.URL.revokeObjectURL(url);
|
| 271 |
+
document.body.removeChild(a);
|
| 272 |
+
|
| 273 |
+
hideResumeOptions();
|
| 274 |
+
})
|
| 275 |
+
.catch(error => {
|
| 276 |
+
console.error('Error:', error);
|
| 277 |
+
alert('Failed to generate resume. Please try again.');
|
| 278 |
+
hideResumeOptions();
|
| 279 |
+
})
|
| 280 |
+
.finally(() => {
|
| 281 |
+
// Remove loading message and re-enable buttons
|
| 282 |
+
if (loadingMsg.parentNode) {
|
| 283 |
+
loadingMsg.parentNode.removeChild(loadingMsg);
|
| 284 |
+
}
|
| 285 |
+
buttons.forEach(btn => btn.disabled = false);
|
| 286 |
+
});
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// Toggle admin dropdown
|
| 290 |
+
function toggleDropdown() {
|
| 291 |
+
const dropdown = document.getElementById('adminDropdown');
|
| 292 |
+
dropdown.classList.toggle('hidden');
|
| 293 |
+
|
| 294 |
+
// Close dropdown when clicking outside
|
| 295 |
+
document.addEventListener('click', function closeDropdown(e) {
|
| 296 |
+
if (!e.target.closest('.relative')) {
|
| 297 |
+
dropdown.classList.add('hidden');
|
| 298 |
+
document.removeEventListener('click', closeDropdown);
|
| 299 |
+
}
|
| 300 |
+
});
|
| 301 |
+
}
|
| 302 |
+
</script>
|
| 303 |
+
{% endblock %}
|
templates/resumes/resume_modern.html
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{ intro.name }} - Resume</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
body {
|
| 15 |
+
font-family: 'Calibri', Arial, sans-serif;
|
| 16 |
+
font-size: 11pt;
|
| 17 |
+
line-height: 1.4;
|
| 18 |
+
color: #333;
|
| 19 |
+
max-width: 1000px;
|
| 20 |
+
margin: 0 auto;
|
| 21 |
+
padding: 30px;
|
| 22 |
+
background-color: #fff;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.header {
|
| 26 |
+
text-align: center;
|
| 27 |
+
margin-bottom: 30px;
|
| 28 |
+
padding-bottom: 20px;
|
| 29 |
+
border-bottom: 3px solid #2c3e50;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.name {
|
| 33 |
+
font-size: 28pt;
|
| 34 |
+
font-weight: 300;
|
| 35 |
+
color: #2c3e50;
|
| 36 |
+
margin-bottom: 10px;
|
| 37 |
+
letter-spacing: 2px;
|
| 38 |
+
text-transform: uppercase;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.contact-info {
|
| 42 |
+
display: flex;
|
| 43 |
+
justify-content: center;
|
| 44 |
+
flex-wrap: wrap;
|
| 45 |
+
gap: 25px;
|
| 46 |
+
font-size: 10pt;
|
| 47 |
+
color: #555;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.contact-info span {
|
| 51 |
+
display: flex;
|
| 52 |
+
align-items: center;
|
| 53 |
+
gap: 6px;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.contact-info i {
|
| 57 |
+
color: #3498db;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.profile-summary {
|
| 61 |
+
margin-bottom: 30px;
|
| 62 |
+
text-align: justify;
|
| 63 |
+
color: #555;
|
| 64 |
+
font-style: italic;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.ai-generated {
|
| 68 |
+
font-size: 9pt;
|
| 69 |
+
color: #7f8c8d;
|
| 70 |
+
text-align: right;
|
| 71 |
+
margin-top: 5px;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.main-content {
|
| 75 |
+
display: grid;
|
| 76 |
+
grid-template-columns: 32% 68%;
|
| 77 |
+
gap: 30px;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.left-column {
|
| 81 |
+
background-color: #f8f9fa;
|
| 82 |
+
padding: 25px;
|
| 83 |
+
border-radius: 8px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.right-column {
|
| 87 |
+
padding: 0 10px;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.section {
|
| 91 |
+
margin-bottom: 30px;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.section-title {
|
| 95 |
+
font-size: 14pt;
|
| 96 |
+
font-weight: 600;
|
| 97 |
+
color: #2c3e50;
|
| 98 |
+
margin-bottom: 15px;
|
| 99 |
+
padding-bottom: 8px;
|
| 100 |
+
border-bottom: 2px solid #3498db;
|
| 101 |
+
text-transform: uppercase;
|
| 102 |
+
letter-spacing: 1px;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.skills-list {
|
| 106 |
+
display: flex;
|
| 107 |
+
flex-wrap: wrap;
|
| 108 |
+
gap: 8px;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.skill-tag {
|
| 112 |
+
background-color: #3498db;
|
| 113 |
+
color: white;
|
| 114 |
+
padding: 6px 12px;
|
| 115 |
+
border-radius: 20px;
|
| 116 |
+
font-size: 10pt;
|
| 117 |
+
font-weight: 500;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.achievements-list {
|
| 121 |
+
display: flex;
|
| 122 |
+
flex-direction: column;
|
| 123 |
+
gap: 8px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.achievement-item {
|
| 127 |
+
display: flex;
|
| 128 |
+
align-items: flex-start;
|
| 129 |
+
gap: 8px;
|
| 130 |
+
font-size: 10pt;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.achievement-item::before {
|
| 134 |
+
content: "▸";
|
| 135 |
+
color: #3498db;
|
| 136 |
+
font-weight: bold;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.experience-item, .project-item, .education-item {
|
| 140 |
+
margin-bottom: 20px;
|
| 141 |
+
padding-bottom: 15px;
|
| 142 |
+
border-bottom: 1px solid #eee;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.experience-item:last-child, .project-item:last-child, .education-item:last-child {
|
| 146 |
+
border-bottom: none;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.item-header {
|
| 150 |
+
display: flex;
|
| 151 |
+
justify-content: space-between;
|
| 152 |
+
align-items: baseline;
|
| 153 |
+
margin-bottom: 8px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.title {
|
| 157 |
+
font-weight: 600;
|
| 158 |
+
font-size: 12pt;
|
| 159 |
+
color: #2c3e50;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.organization {
|
| 163 |
+
color: #3498db;
|
| 164 |
+
font-style: italic;
|
| 165 |
+
font-size: 11pt;
|
| 166 |
+
margin-left: 10px;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.date {
|
| 170 |
+
color: #7f8c8d;
|
| 171 |
+
font-size: 10pt;
|
| 172 |
+
font-weight: 500;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.remarks {
|
| 176 |
+
margin-top: 8px;
|
| 177 |
+
text-align: justify;
|
| 178 |
+
color: #555;
|
| 179 |
+
font-size: 10pt;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
@media print {
|
| 183 |
+
body {
|
| 184 |
+
padding: 20px;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.main-content {
|
| 188 |
+
gap: 20px;
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
</style>
|
| 192 |
+
</head>
|
| 193 |
+
<body>
|
| 194 |
+
<!-- Header Section -->
|
| 195 |
+
<div class="header">
|
| 196 |
+
<h1 class="name">{{ intro.name }}</h1>
|
| 197 |
+
<div class="contact-info">
|
| 198 |
+
<span><i>📧</i> {{ intro.email }}</span>
|
| 199 |
+
<span><i>📱</i> {{ intro.phone }}</span>
|
| 200 |
+
{% if intro.linkedin %}
|
| 201 |
+
<span><i>💼</i> LinkedIn</span>
|
| 202 |
+
{% endif %}
|
| 203 |
+
{% if intro.github %}
|
| 204 |
+
<span><i>💻</i> GitHub</span>
|
| 205 |
+
{% endif %}
|
| 206 |
+
{% if intro.website %}
|
| 207 |
+
<span><i>🌐</i> Website</span>
|
| 208 |
+
{% endif %}
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<!-- Profile Summary -->
|
| 213 |
+
{% if summary %}
|
| 214 |
+
<div class="profile-summary">
|
| 215 |
+
<p>{{ summary.summary }}</p>
|
| 216 |
+
{% if summary.ai_generated %}
|
| 217 |
+
<p class="ai-generated">AI Generated</p>
|
| 218 |
+
{% endif %}
|
| 219 |
+
</div>
|
| 220 |
+
{% endif %}
|
| 221 |
+
|
| 222 |
+
<!-- Main Content with Two Columns -->
|
| 223 |
+
<div class="main-content">
|
| 224 |
+
<!-- Left Column -->
|
| 225 |
+
<div class="left-column">
|
| 226 |
+
<!-- Skills Section -->
|
| 227 |
+
{% if skills %}
|
| 228 |
+
<div class="section">
|
| 229 |
+
<h2 class="section-title">Skills</h2>
|
| 230 |
+
<div class="skills-list">
|
| 231 |
+
{% for skill in skills %}
|
| 232 |
+
<span class="skill-tag">{{ skill.skill }}</span>
|
| 233 |
+
{% endfor %}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
{% endif %}
|
| 237 |
+
|
| 238 |
+
<!-- Achievements Section -->
|
| 239 |
+
{% if achievements %}
|
| 240 |
+
<div class="section">
|
| 241 |
+
<h2 class="section-title">Achievements</h2>
|
| 242 |
+
<div class="achievements-list">
|
| 243 |
+
{% for achievement in achievements %}
|
| 244 |
+
<div class="achievement-item">{{ achievement.achievement }}</div>
|
| 245 |
+
{% endfor %}
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
{% endif %}
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<!-- Right Column -->
|
| 252 |
+
<div class="right-column">
|
| 253 |
+
<!-- Dynamic Sections -->
|
| 254 |
+
{% for section_name in section_order %}
|
| 255 |
+
{% if section_name == 'work_experience' and work_experiences %}
|
| 256 |
+
<div class="section">
|
| 257 |
+
<h2 class="section-title">Work Experience</h2>
|
| 258 |
+
{% for exp in work_experiences %}
|
| 259 |
+
<div class="experience-item">
|
| 260 |
+
<div class="item-header">
|
| 261 |
+
<div>
|
| 262 |
+
<span class="title">{{ exp.title }}</span>
|
| 263 |
+
<span class="organization">{{ exp.organization }}</span>
|
| 264 |
+
</div>
|
| 265 |
+
<span class="date">
|
| 266 |
+
{{ exp.start_month }}/{{ exp.start_year }} -
|
| 267 |
+
{% if exp.end_year %}{{ exp.end_month }}/{{ exp.end_year }}{% else %}Present{% endif %}
|
| 268 |
+
</span>
|
| 269 |
+
</div>
|
| 270 |
+
{% if exp.remarks %}
|
| 271 |
+
<p class="remarks">{{ exp.remarks }}</p>
|
| 272 |
+
{% endif %}
|
| 273 |
+
</div>
|
| 274 |
+
{% endfor %}
|
| 275 |
+
</div>
|
| 276 |
+
{% endif %}
|
| 277 |
+
|
| 278 |
+
{% if section_name == 'projects' and projects %}
|
| 279 |
+
<div class="section">
|
| 280 |
+
<h2 class="section-title">Projects</h2>
|
| 281 |
+
{% for project in projects %}
|
| 282 |
+
<div class="project-item">
|
| 283 |
+
<div class="item-header">
|
| 284 |
+
<div>
|
| 285 |
+
<span class="title">{{ project.title }}</span>
|
| 286 |
+
{% if project.organization %}
|
| 287 |
+
<span class="organization">{{ project.organization }}</span>
|
| 288 |
+
{% endif %}
|
| 289 |
+
</div>
|
| 290 |
+
<span class="date">
|
| 291 |
+
{{ project.start_month }}/{{ project.start_year }} -
|
| 292 |
+
{% if project.end_year %}{{ project.end_month }}/{{ project.end_year }}{% else %}Present{% endif %}
|
| 293 |
+
</span>
|
| 294 |
+
</div>
|
| 295 |
+
{% if project.remarks %}
|
| 296 |
+
<p class="remarks">{{ project.remarks }}</p>
|
| 297 |
+
{% endif %}
|
| 298 |
+
</div>
|
| 299 |
+
{% endfor %}
|
| 300 |
+
</div>
|
| 301 |
+
{% endif %}
|
| 302 |
+
|
| 303 |
+
{% if section_name == 'education' and educations %}
|
| 304 |
+
<div class="section">
|
| 305 |
+
<h2 class="section-title">Education</h2>
|
| 306 |
+
{% for edu in educations %}
|
| 307 |
+
<div class="education-item">
|
| 308 |
+
<div class="item-header">
|
| 309 |
+
<div>
|
| 310 |
+
<span class="title">{{ edu.title }}</span>
|
| 311 |
+
<span class="organization">{{ edu.organization }}</span>
|
| 312 |
+
</div>
|
| 313 |
+
<span class="date">
|
| 314 |
+
{{ edu.start_month }}/{{ edu.start_year }} -
|
| 315 |
+
{% if edu.end_year %}{{ edu.end_month }}/{{ edu.end_year }}{% else %}Present{% endif %}
|
| 316 |
+
</span>
|
| 317 |
+
</div>
|
| 318 |
+
{% if edu.remarks %}
|
| 319 |
+
<p class="remarks">{{ edu.remarks }}</p>
|
| 320 |
+
{% endif %}
|
| 321 |
+
</div>
|
| 322 |
+
{% endfor %}
|
| 323 |
+
</div>
|
| 324 |
+
{% endif %}
|
| 325 |
+
{% endfor %}
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
</body>
|
| 329 |
+
</html>
|
templates/resumes/resume_standard.html
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{ intro.name }} - Resume</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
body {
|
| 15 |
+
font-family: 'Times New Roman', serif;
|
| 16 |
+
font-size: 12pt;
|
| 17 |
+
line-height: 1.4;
|
| 18 |
+
color: #000;
|
| 19 |
+
max-width: 800px;
|
| 20 |
+
margin: 0 auto;
|
| 21 |
+
padding: 20px;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.header {
|
| 25 |
+
text-align: center;
|
| 26 |
+
margin-bottom: 30px;
|
| 27 |
+
border-bottom: 2px solid #333;
|
| 28 |
+
padding-bottom: 20px;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.name {
|
| 32 |
+
font-size: 24pt;
|
| 33 |
+
font-weight: bold;
|
| 34 |
+
margin-bottom: 10px;
|
| 35 |
+
text-transform: uppercase;
|
| 36 |
+
letter-spacing: 1px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.contact-info {
|
| 40 |
+
display: flex;
|
| 41 |
+
justify-content: center;
|
| 42 |
+
flex-wrap: wrap;
|
| 43 |
+
gap: 20px;
|
| 44 |
+
font-size: 11pt;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.contact-info span {
|
| 48 |
+
display: flex;
|
| 49 |
+
align-items: center;
|
| 50 |
+
gap: 5px;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.section {
|
| 54 |
+
margin-bottom: 25px;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.section-title {
|
| 58 |
+
font-size: 16pt;
|
| 59 |
+
font-weight: bold;
|
| 60 |
+
margin-bottom: 15px;
|
| 61 |
+
text-transform: uppercase;
|
| 62 |
+
border-bottom: 1px solid #999;
|
| 63 |
+
padding-bottom: 5px;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.experience-item, .project-item, .education-item {
|
| 67 |
+
margin-bottom: 15px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.item-header {
|
| 71 |
+
display: flex;
|
| 72 |
+
justify-content: space-between;
|
| 73 |
+
align-items: baseline;
|
| 74 |
+
margin-bottom: 5px;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.title {
|
| 78 |
+
font-weight: bold;
|
| 79 |
+
font-size: 13pt;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.organization {
|
| 83 |
+
font-style: italic;
|
| 84 |
+
color: #555;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.date {
|
| 88 |
+
color: #666;
|
| 89 |
+
font-size: 11pt;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.remarks {
|
| 93 |
+
margin-top: 5px;
|
| 94 |
+
text-align: justify;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.skills-list, .achievements-list {
|
| 98 |
+
display: flex;
|
| 99 |
+
flex-wrap: wrap;
|
| 100 |
+
gap: 8px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.skill-tag, .achievement-tag {
|
| 104 |
+
background-color: #f0f0f0;
|
| 105 |
+
padding: 4px 8px;
|
| 106 |
+
border-radius: 3px;
|
| 107 |
+
font-size: 11pt;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.profile-summary {
|
| 111 |
+
text-align: justify;
|
| 112 |
+
margin-bottom: 10px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.ai-generated {
|
| 116 |
+
font-size: 10pt;
|
| 117 |
+
color: #666;
|
| 118 |
+
font-style: italic;
|
| 119 |
+
}
|
| 120 |
+
</style>
|
| 121 |
+
</head>
|
| 122 |
+
<body>
|
| 123 |
+
<!-- Header Section -->
|
| 124 |
+
<div class="header">
|
| 125 |
+
<h1 class="name">{{ intro.name }}</h1>
|
| 126 |
+
<div class="contact-info">
|
| 127 |
+
<span><i>📧</i> {{ intro.email }}</span>
|
| 128 |
+
<span><i>📱</i> {{ intro.phone }}</span>
|
| 129 |
+
{% if intro.linkedin %}
|
| 130 |
+
<span><i>💼</i> LinkedIn</span>
|
| 131 |
+
{% endif %}
|
| 132 |
+
{% if intro.github %}
|
| 133 |
+
<span><i>💻</i> GitHub</span>
|
| 134 |
+
{% endif %}
|
| 135 |
+
{% if intro.website %}
|
| 136 |
+
<span><i>🌐</i> Website</span>
|
| 137 |
+
{% endif %}
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
<!-- Profile Summary -->
|
| 142 |
+
{% if summary %}
|
| 143 |
+
<div class="section">
|
| 144 |
+
<h2 class="section-title">Professional Summary</h2>
|
| 145 |
+
<p class="profile-summary">{{ summary.summary }}</p>
|
| 146 |
+
{% if summary.ai_generated %}
|
| 147 |
+
<p class="ai-generated">AI Generated</p>
|
| 148 |
+
{% endif %}
|
| 149 |
+
</div>
|
| 150 |
+
{% endif %}
|
| 151 |
+
|
| 152 |
+
<!-- Dynamic Sections -->
|
| 153 |
+
{% for section_name in section_order %}
|
| 154 |
+
{% if section_name == 'work_experience' and work_experiences %}
|
| 155 |
+
<div class="section">
|
| 156 |
+
<h2 class="section-title">Work Experience</h2>
|
| 157 |
+
{% for exp in work_experiences %}
|
| 158 |
+
<div class="experience-item">
|
| 159 |
+
<div class="item-header">
|
| 160 |
+
<div>
|
| 161 |
+
<span class="title">{{ exp.title }}</span>
|
| 162 |
+
<span class="organization">at {{ exp.organization }}</span>
|
| 163 |
+
</div>
|
| 164 |
+
<span class="date">
|
| 165 |
+
{{ exp.start_month }}/{{ exp.start_year }} -
|
| 166 |
+
{% if exp.end_year %}{{ exp.end_month }}/{{ exp.end_year }}{% else %}Present{% endif %}
|
| 167 |
+
</span>
|
| 168 |
+
</div>
|
| 169 |
+
{% if exp.remarks %}
|
| 170 |
+
<p class="remarks">{{ exp.remarks }}</p>
|
| 171 |
+
{% endif %}
|
| 172 |
+
</div>
|
| 173 |
+
{% endfor %}
|
| 174 |
+
</div>
|
| 175 |
+
{% endif %}
|
| 176 |
+
|
| 177 |
+
{% if section_name == 'projects' and projects %}
|
| 178 |
+
<div class="section">
|
| 179 |
+
<h2 class="section-title">Projects</h2>
|
| 180 |
+
{% for project in projects %}
|
| 181 |
+
<div class="project-item">
|
| 182 |
+
<div class="item-header">
|
| 183 |
+
<div>
|
| 184 |
+
<span class="title">{{ project.title }}</span>
|
| 185 |
+
{% if project.organization %}
|
| 186 |
+
<span class="organization">at {{ project.organization }}</span>
|
| 187 |
+
{% endif %}
|
| 188 |
+
</div>
|
| 189 |
+
<span class="date">
|
| 190 |
+
{{ project.start_month }}/{{ project.start_year }} -
|
| 191 |
+
{% if project.end_year %}{{ project.end_month }}/{{ project.end_year }}{% else %}Present{% endif %}
|
| 192 |
+
</span>
|
| 193 |
+
</div>
|
| 194 |
+
{% if project.remarks %}
|
| 195 |
+
<p class="remarks">{{ project.remarks }}</p>
|
| 196 |
+
{% endif %}
|
| 197 |
+
</div>
|
| 198 |
+
{% endfor %}
|
| 199 |
+
</div>
|
| 200 |
+
{% endif %}
|
| 201 |
+
|
| 202 |
+
{% if section_name == 'education' and educations %}
|
| 203 |
+
<div class="section">
|
| 204 |
+
<h2 class="section-title">Education</h2>
|
| 205 |
+
{% for edu in educations %}
|
| 206 |
+
<div class="education-item">
|
| 207 |
+
<div class="item-header">
|
| 208 |
+
<div>
|
| 209 |
+
<span class="title">{{ edu.title }}</span>
|
| 210 |
+
<span class="organization">at {{ edu.organization }}</span>
|
| 211 |
+
</div>
|
| 212 |
+
<span class="date">
|
| 213 |
+
{{ edu.start_month }}/{{ edu.start_year }} -
|
| 214 |
+
{% if edu.end_year %}{{ edu.end_month }}/{{ edu.end_year }}{% else %}Present{% endif %}
|
| 215 |
+
</span>
|
| 216 |
+
</div>
|
| 217 |
+
{% if edu.remarks %}
|
| 218 |
+
<p class="remarks">{{ edu.remarks }}</p>
|
| 219 |
+
{% endif %}
|
| 220 |
+
</div>
|
| 221 |
+
{% endfor %}
|
| 222 |
+
</div>
|
| 223 |
+
{% endif %}
|
| 224 |
+
|
| 225 |
+
{% if section_name == 'skills' and skills %}
|
| 226 |
+
<div class="section">
|
| 227 |
+
<h2 class="section-title">Skills</h2>
|
| 228 |
+
<div class="skills-list">
|
| 229 |
+
{% for skill in skills %}
|
| 230 |
+
<span class="skill-tag">{{ skill.skill }}</span>
|
| 231 |
+
{% endfor %}
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
{% endif %}
|
| 235 |
+
|
| 236 |
+
{% if section_name == 'achievements' and achievements %}
|
| 237 |
+
<div class="section">
|
| 238 |
+
<h2 class="section-title">Achievements</h2>
|
| 239 |
+
<div class="achievements-list">
|
| 240 |
+
{% for achievement in achievements %}
|
| 241 |
+
<span class="achievement-tag">{{ achievement.achievement }}</span>
|
| 242 |
+
{% endfor %}
|
| 243 |
+
</div>
|
| 244 |
+
</div>
|
| 245 |
+
{% endif %}
|
| 246 |
+
{% endfor %}
|
| 247 |
+
</body>
|
| 248 |
+
</html>
|
templates/signin.html
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Sign In - AI Resume Builder{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="min-h-screen flex bg-gray-50">
|
| 7 |
+
<!-- Left side - Image Carousel -->
|
| 8 |
+
<div class="w-1/2 hidden lg:block relative overflow-hidden">
|
| 9 |
+
<!-- Carousel Images -->
|
| 10 |
+
<div class="relative h-full">
|
| 11 |
+
<div id="carousel-images" class="relative h-full">
|
| 12 |
+
<!-- Images will be dynamically inserted here -->
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<!-- Carousel Content Overlay -->
|
| 17 |
+
<div class="absolute inset-0 flex flex-col justify-center items-center text-white p-8 text-center">
|
| 18 |
+
<div class="max-w-lg">
|
| 19 |
+
<h1 class="text-5xl font-bold mb-6 drop-shadow-lg">
|
| 20 |
+
AI Resume Builder
|
| 21 |
+
</h1>
|
| 22 |
+
<p class="text-xl mb-8 drop-shadow-md">
|
| 23 |
+
Create professional resumes with the power of AI. Stand out from the crowd with intelligent, tailored resumes.
|
| 24 |
+
</p>
|
| 25 |
+
<div class="flex justify-center space-x-8 mb-8">
|
| 26 |
+
<div class="text-center">
|
| 27 |
+
<div class="text-3xl font-bold mb-2">10K+</div>
|
| 28 |
+
<div class="text-sm opacity-90">Resumes Created</div>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="text-center">
|
| 31 |
+
<div class="text-3xl font-bold mb-2">95%</div>
|
| 32 |
+
<div class="text-sm opacity-90">Success Rate</div>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="text-center">
|
| 35 |
+
<div class="text-3xl font-bold mb-2">4.8★</div>
|
| 36 |
+
<div class="text-sm opacity-90">User Rating</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<!-- Carousel Indicators -->
|
| 43 |
+
<div id="carousel-indicators" class="absolute bottom-8 left-1/2 transform -translate-x-1/2 flex space-x-2">
|
| 44 |
+
<!-- Indicators will be dynamically inserted here -->
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<!-- Right side - Sign In Form -->
|
| 49 |
+
<div class="w-full lg:w-1/2 flex items-center justify-center p-8">
|
| 50 |
+
<div class="max-w-md w-full space-y-8">
|
| 51 |
+
<div class="text-center">
|
| 52 |
+
<h2 class="text-2xl font-semibold text-gray-800 mb-6">
|
| 53 |
+
Sign in to your account
|
| 54 |
+
</h2>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<form method="POST" action="{{ url_for('signin') }}" class="mt-8 space-y-6">
|
| 58 |
+
|
| 59 |
+
<div class="space-y-4">
|
| 60 |
+
<div>
|
| 61 |
+
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
| 62 |
+
Email address
|
| 63 |
+
</label>
|
| 64 |
+
<input
|
| 65 |
+
id="email"
|
| 66 |
+
name="email"
|
| 67 |
+
type="email"
|
| 68 |
+
autocomplete="email"
|
| 69 |
+
required
|
| 70 |
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
| 71 |
+
placeholder="Enter your email"
|
| 72 |
+
value="{{ request.form.email or '' }}"
|
| 73 |
+
/>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div>
|
| 77 |
+
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
| 78 |
+
Password
|
| 79 |
+
</label>
|
| 80 |
+
<input
|
| 81 |
+
id="password"
|
| 82 |
+
name="password"
|
| 83 |
+
type="password"
|
| 84 |
+
autocomplete="current-password"
|
| 85 |
+
required
|
| 86 |
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
| 87 |
+
placeholder="Enter your password"
|
| 88 |
+
/>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div class="flex items-center justify-between">
|
| 93 |
+
<div class="text-sm">
|
| 94 |
+
<a href="{{ url_for('forgot_password') }}" class="font-medium text-blue-600 hover:text-blue-500">
|
| 95 |
+
Forgot your password?
|
| 96 |
+
</a>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div>
|
| 101 |
+
<button
|
| 102 |
+
type="submit"
|
| 103 |
+
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
| 104 |
+
>
|
| 105 |
+
Sign in
|
| 106 |
+
</button>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div class="text-center">
|
| 110 |
+
<a
|
| 111 |
+
href="{{ url_for('github_auth') }}"
|
| 112 |
+
class="w-full flex items-center justify-center py-3 px-4 border border-gray-300 rounded-lg shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors inline-block"
|
| 113 |
+
>
|
| 114 |
+
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
| 115 |
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
| 116 |
+
</svg>
|
| 117 |
+
Sign in with GitHub
|
| 118 |
+
</a>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div class="text-center">
|
| 122 |
+
<p class="text-sm text-gray-600">
|
| 123 |
+
Don't have an account?
|
| 124 |
+
<a href="{{ url_for('signup') }}" class="font-medium text-blue-600 hover:text-blue-500">
|
| 125 |
+
Sign up
|
| 126 |
+
</a>
|
| 127 |
+
</p>
|
| 128 |
+
</div>
|
| 129 |
+
</form>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
{% endblock %}
|
| 134 |
+
|
| 135 |
+
{% block scripts %}
|
| 136 |
+
<script>
|
| 137 |
+
// Carousel functionality
|
| 138 |
+
const carouselImages = [
|
| 139 |
+
'https://picsum.photos/seed/resume-builder-1/1200/800.jpg',
|
| 140 |
+
'https://picsum.photos/seed/resume-builder-2/1200/800.jpg',
|
| 141 |
+
'https://picsum.photos/seed/resume-builder-3/1200/800.jpg',
|
| 142 |
+
'https://picsum.photos/seed/resume-builder-4/1200/800.jpg',
|
| 143 |
+
];
|
| 144 |
+
|
| 145 |
+
let currentImageIndex = 0;
|
| 146 |
+
|
| 147 |
+
// Initialize carousel
|
| 148 |
+
function initCarousel() {
|
| 149 |
+
const imagesContainer = document.getElementById('carousel-images');
|
| 150 |
+
const indicatorsContainer = document.getElementById('carousel-indicators');
|
| 151 |
+
|
| 152 |
+
// Create image elements
|
| 153 |
+
carouselImages.forEach((image, index) => {
|
| 154 |
+
const imageDiv = document.createElement('div');
|
| 155 |
+
imageDiv.className = `absolute inset-0 carousel-image ${index === 0 ? 'opacity-100' : 'opacity-0'}`;
|
| 156 |
+
imageDiv.innerHTML = `
|
| 157 |
+
<img src="${image}" alt="Resume Builder ${index + 1}" class="w-full h-full object-cover">
|
| 158 |
+
<div class="absolute inset-0 bg-black bg-opacity-40"></div>
|
| 159 |
+
`;
|
| 160 |
+
imagesContainer.appendChild(imageDiv);
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
// Create indicator elements
|
| 164 |
+
carouselImages.forEach((_, index) => {
|
| 165 |
+
const indicator = document.createElement('button');
|
| 166 |
+
indicator.className = `w-3 h-3 rounded-full transition-all duration-300 ${
|
| 167 |
+
index === 0 ? 'bg-white w-8' : 'bg-white bg-opacity-50 hover:bg-opacity-75'
|
| 168 |
+
}`;
|
| 169 |
+
indicator.onclick = () => showImage(index);
|
| 170 |
+
indicatorsContainer.appendChild(indicator);
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
// Auto-rotate carousel
|
| 174 |
+
setInterval(() => {
|
| 175 |
+
currentImageIndex = (currentImageIndex + 1) % carouselImages.length;
|
| 176 |
+
showImage(currentImageIndex);
|
| 177 |
+
}, 5000);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// Show specific image
|
| 181 |
+
function showImage(index) {
|
| 182 |
+
const images = document.querySelectorAll('.carousel-image');
|
| 183 |
+
const indicators = document.querySelectorAll('#carousel-indicators button');
|
| 184 |
+
|
| 185 |
+
images.forEach((img, i) => {
|
| 186 |
+
img.className = `absolute inset-0 carousel-image ${i === index ? 'opacity-100' : 'opacity-0'}`;
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
indicators.forEach((indicator, i) => {
|
| 190 |
+
indicator.className = `w-3 h-3 rounded-full transition-all duration-300 ${
|
| 191 |
+
i === index ? 'bg-white w-8' : 'bg-white bg-opacity-50 hover:bg-opacity-75'
|
| 192 |
+
}`;
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
currentImageIndex = index;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
// Initialize carousel when page loads
|
| 200 |
+
document.addEventListener('DOMContentLoaded', initCarousel);
|
| 201 |
+
</script>
|
| 202 |
+
{% endblock %}
|
templates/signup.html
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Sign Up - AI Resume Builder{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="min-h-screen flex bg-gray-50">
|
| 7 |
+
<!-- Left side - Image Carousel -->
|
| 8 |
+
<div class="w-1/2 hidden lg:block relative overflow-hidden">
|
| 9 |
+
<!-- Carousel Images -->
|
| 10 |
+
<div class="relative h-full">
|
| 11 |
+
<div id="carousel-images" class="relative h-full">
|
| 12 |
+
<!-- Images will be dynamically inserted here -->
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<!-- Carousel Content Overlay -->
|
| 17 |
+
<div class="absolute inset-0 flex flex-col justify-center items-center text-white p-8 text-center">
|
| 18 |
+
<div class="max-w-lg">
|
| 19 |
+
<h1 class="text-5xl font-bold mb-6 drop-shadow-lg">
|
| 20 |
+
Join AI Resume Builder
|
| 21 |
+
</h1>
|
| 22 |
+
<p class="text-xl mb-8 drop-shadow-md">
|
| 23 |
+
Start creating professional resumes in minutes. Join thousands of professionals who've transformed their careers.
|
| 24 |
+
</p>
|
| 25 |
+
<div class="flex justify-center space-x-8 mb-8">
|
| 26 |
+
<div class="text-center">
|
| 27 |
+
<div class="text-3xl font-bold mb-2">Free</div>
|
| 28 |
+
<div class="text-sm opacity-90">Get Started</div>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="text-center">
|
| 31 |
+
<div class="text-3xl font-bold mb-2">2 Min</div>
|
| 32 |
+
<div class="text-sm opacity-90">Setup Time</div>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="text-center">
|
| 35 |
+
<div class="text-3xl font-bold mb-2">AI</div>
|
| 36 |
+
<div class="text-sm opacity-90">Powered</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<!-- Carousel Indicators -->
|
| 43 |
+
<div id="carousel-indicators" class="absolute bottom-8 left-1/2 transform -translate-x-1/2 flex space-x-2">
|
| 44 |
+
<!-- Indicators will be dynamically inserted here -->
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<!-- Right side - Sign Up Form -->
|
| 49 |
+
<div class="w-full lg:w-1/2 flex items-center justify-center p-8">
|
| 50 |
+
<div class="max-w-md w-full space-y-8">
|
| 51 |
+
<div class="text-center">
|
| 52 |
+
<h2 class="text-2xl font-semibold text-gray-800 mb-6">
|
| 53 |
+
Create your account
|
| 54 |
+
</h2>
|
| 55 |
+
<p class="text-gray-600">
|
| 56 |
+
Join thousands of professionals building amazing resumes with AI
|
| 57 |
+
</p>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<form method="POST" action="{{ url_for('signup') }}" class="mt-8 space-y-6">
|
| 61 |
+
|
| 62 |
+
<div class="space-y-4">
|
| 63 |
+
<div>
|
| 64 |
+
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
|
| 65 |
+
Full Name *
|
| 66 |
+
</label>
|
| 67 |
+
<input
|
| 68 |
+
id="name"
|
| 69 |
+
name="name"
|
| 70 |
+
type="text"
|
| 71 |
+
autocomplete="name"
|
| 72 |
+
required
|
| 73 |
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
| 74 |
+
placeholder="Enter your full name"
|
| 75 |
+
value="{{ request.form.name or '' }}"
|
| 76 |
+
/>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div>
|
| 80 |
+
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
| 81 |
+
Email Address *
|
| 82 |
+
</label>
|
| 83 |
+
<input
|
| 84 |
+
id="email"
|
| 85 |
+
name="email"
|
| 86 |
+
type="email"
|
| 87 |
+
autocomplete="email"
|
| 88 |
+
required
|
| 89 |
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
| 90 |
+
placeholder="Enter your email"
|
| 91 |
+
value="{{ request.form.email or '' }}"
|
| 92 |
+
/>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<div>
|
| 96 |
+
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
| 97 |
+
Password *
|
| 98 |
+
</label>
|
| 99 |
+
<input
|
| 100 |
+
id="password"
|
| 101 |
+
name="password"
|
| 102 |
+
type="password"
|
| 103 |
+
autocomplete="new-password"
|
| 104 |
+
required
|
| 105 |
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
| 106 |
+
placeholder="Create a password"
|
| 107 |
+
/>
|
| 108 |
+
<p class="mt-1 text-xs text-gray-500">
|
| 109 |
+
Must be at least 8 characters long
|
| 110 |
+
</p>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div>
|
| 114 |
+
<label for="password_confirm" class="block text-sm font-medium text-gray-700 mb-2">
|
| 115 |
+
Confirm Password *
|
| 116 |
+
</label>
|
| 117 |
+
<input
|
| 118 |
+
id="password_confirm"
|
| 119 |
+
name="password_confirm"
|
| 120 |
+
type="password"
|
| 121 |
+
autocomplete="new-password"
|
| 122 |
+
required
|
| 123 |
+
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
| 124 |
+
placeholder="Confirm your password"
|
| 125 |
+
/>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div>
|
| 130 |
+
<button
|
| 131 |
+
type="submit"
|
| 132 |
+
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
| 133 |
+
>
|
| 134 |
+
Create account
|
| 135 |
+
</button>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div class="text-center">
|
| 139 |
+
<a
|
| 140 |
+
href="{{ url_for('github_auth') }}"
|
| 141 |
+
class="w-full flex items-center justify-center py-3 px-4 border border-gray-300 rounded-lg shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors inline-block"
|
| 142 |
+
>
|
| 143 |
+
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
| 144 |
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
| 145 |
+
</svg>
|
| 146 |
+
Sign up with GitHub
|
| 147 |
+
</a>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div class="text-center">
|
| 151 |
+
<p class="text-sm text-gray-600">
|
| 152 |
+
Already have an account?
|
| 153 |
+
<a href="{{ url_for('signin') }}" class="font-medium text-blue-600 hover:text-blue-500">
|
| 154 |
+
Sign in
|
| 155 |
+
</a>
|
| 156 |
+
</p>
|
| 157 |
+
</div>
|
| 158 |
+
</form>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
{% endblock %}
|
| 163 |
+
|
| 164 |
+
{% block scripts %}
|
| 165 |
+
<script>
|
| 166 |
+
// Carousel functionality
|
| 167 |
+
const carouselImages = [
|
| 168 |
+
'https://picsum.photos/seed/resume-builder-1/1200/800.jpg',
|
| 169 |
+
'https://picsum.photos/seed/resume-builder-2/1200/800.jpg',
|
| 170 |
+
'https://picsum.photos/seed/resume-builder-3/1200/800.jpg',
|
| 171 |
+
'https://picsum.photos/seed/resume-builder-4/1200/800.jpg',
|
| 172 |
+
];
|
| 173 |
+
|
| 174 |
+
let currentImageIndex = 0;
|
| 175 |
+
|
| 176 |
+
// Initialize carousel
|
| 177 |
+
function initCarousel() {
|
| 178 |
+
const imagesContainer = document.getElementById('carousel-images');
|
| 179 |
+
const indicatorsContainer = document.getElementById('carousel-indicators');
|
| 180 |
+
|
| 181 |
+
// Create image elements
|
| 182 |
+
carouselImages.forEach((image, index) => {
|
| 183 |
+
const imageDiv = document.createElement('div');
|
| 184 |
+
imageDiv.className = `absolute inset-0 carousel-image ${index === 0 ? 'opacity-100' : 'opacity-0'}`;
|
| 185 |
+
imageDiv.innerHTML = `
|
| 186 |
+
<img src="${image}" alt="Resume Builder ${index + 1}" class="w-full h-full object-cover">
|
| 187 |
+
<div class="absolute inset-0 bg-black bg-opacity-40"></div>
|
| 188 |
+
`;
|
| 189 |
+
imagesContainer.appendChild(imageDiv);
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
// Create indicator elements
|
| 193 |
+
carouselImages.forEach((_, index) => {
|
| 194 |
+
const indicator = document.createElement('button');
|
| 195 |
+
indicator.className = `w-3 h-3 rounded-full transition-all duration-300 ${
|
| 196 |
+
index === 0 ? 'bg-white w-8' : 'bg-white bg-opacity-50 hover:bg-opacity-75'
|
| 197 |
+
}`;
|
| 198 |
+
indicator.onclick = () => showImage(index);
|
| 199 |
+
indicatorsContainer.appendChild(indicator);
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// Auto-rotate carousel
|
| 203 |
+
setInterval(() => {
|
| 204 |
+
currentImageIndex = (currentImageIndex + 1) % carouselImages.length;
|
| 205 |
+
showImage(currentImageIndex);
|
| 206 |
+
}, 5000);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Show specific image
|
| 210 |
+
function showImage(index) {
|
| 211 |
+
const images = document.querySelectorAll('.carousel-image');
|
| 212 |
+
const indicators = document.querySelectorAll('#carousel-indicators button');
|
| 213 |
+
|
| 214 |
+
images.forEach((img, i) => {
|
| 215 |
+
img.className = `absolute inset-0 carousel-image ${i === index ? 'opacity-100' : 'opacity-0'}`;
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
indicators.forEach((indicator, i) => {
|
| 219 |
+
indicator.className = `w-3 h-3 rounded-full transition-all duration-300 ${
|
| 220 |
+
i === index ? 'bg-white w-8' : 'bg-white bg-opacity-50 hover:bg-opacity-75'
|
| 221 |
+
}`;
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
currentImageIndex = index;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// GitHub signup (placeholder for now)
|
| 228 |
+
function handleGitHubSignup() {
|
| 229 |
+
alert('GitHub OAuth will be implemented in the future. Please use email/password for now.');
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Initialize carousel when page loads
|
| 233 |
+
document.addEventListener('DOMContentLoaded', initCarousel);
|
| 234 |
+
</script>
|
| 235 |
+
{% endblock %}
|
test_api.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test the PDF generation API endpoints.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
# Test the application endpoints
|
| 11 |
+
BASE_URL = "http://127.0.0.1:5000"
|
| 12 |
+
|
| 13 |
+
def test_api_endpoints():
|
| 14 |
+
"""Test the PDF generation endpoints."""
|
| 15 |
+
|
| 16 |
+
# First, check if the app is running
|
| 17 |
+
try:
|
| 18 |
+
response = requests.get(f"{BASE_URL}/")
|
| 19 |
+
if response.status_code == 200:
|
| 20 |
+
print("✓ Flask app is running")
|
| 21 |
+
else:
|
| 22 |
+
print("✗ Flask app is not responding properly")
|
| 23 |
+
return
|
| 24 |
+
except:
|
| 25 |
+
print("✗ Flask app is not running. Please start it with 'python app.py'")
|
| 26 |
+
return
|
| 27 |
+
|
| 28 |
+
# Test data - you'll need to be logged in first
|
| 29 |
+
print("\nTo test the PDF generation endpoints:")
|
| 30 |
+
print("1. Go to http://127.0.0.1:5000 and sign up/login")
|
| 31 |
+
print("2. Create a profile with some data")
|
| 32 |
+
print("3. Try generating PDF resumes from the profile page")
|
| 33 |
+
|
| 34 |
+
# The actual endpoints that will be called:
|
| 35 |
+
print("\nPDF generation endpoints:")
|
| 36 |
+
print("- POST /profile/generate-pdf/standard")
|
| 37 |
+
print("- POST /profile/generate-pdf/modern")
|
| 38 |
+
print("- POST /profile/generate-word")
|
| 39 |
+
|
| 40 |
+
if __name__ == "__main__":
|
| 41 |
+
test_api_endpoints()
|
test_mock_models.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test PDF generation with actual model structure.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
import uuid
|
| 8 |
+
from pdf_generator import create_pdf_resume
|
| 9 |
+
from utils import log_info, log_error
|
| 10 |
+
|
| 11 |
+
# Test with mock objects that mimic the model structure
|
| 12 |
+
class MockWorkExperience:
|
| 13 |
+
def __init__(self, title, organization, start_month, start_year, end_month=None, end_year=None, remarks=""):
|
| 14 |
+
self.title = title
|
| 15 |
+
self.organization = organization
|
| 16 |
+
self.start_month = start_month
|
| 17 |
+
self.start_year = start_year
|
| 18 |
+
self.end_month = end_month
|
| 19 |
+
self.end_year = end_year
|
| 20 |
+
self.remarks = remarks
|
| 21 |
+
|
| 22 |
+
class MockProject:
|
| 23 |
+
def __init__(self, title, organization, start_month, start_year, end_month=None, end_year=None, remarks=""):
|
| 24 |
+
self.title = title
|
| 25 |
+
self.organization = organization
|
| 26 |
+
self.start_month = start_month
|
| 27 |
+
self.start_year = start_year
|
| 28 |
+
self.end_month = end_month
|
| 29 |
+
self.end_year = end_year
|
| 30 |
+
self.remarks = remarks
|
| 31 |
+
|
| 32 |
+
class MockEducation:
|
| 33 |
+
def __init__(self, title, organization, start_month, start_year, end_month=None, end_year=None, remarks=""):
|
| 34 |
+
self.title = title
|
| 35 |
+
self.organization = organization
|
| 36 |
+
self.start_month = start_month
|
| 37 |
+
self.start_year = start_year
|
| 38 |
+
self.end_month = end_month
|
| 39 |
+
self.end_year = end_year
|
| 40 |
+
self.remarks = remarks
|
| 41 |
+
|
| 42 |
+
class MockSkill:
|
| 43 |
+
def __init__(self, skill):
|
| 44 |
+
self.skill = skill
|
| 45 |
+
|
| 46 |
+
class MockAchievement:
|
| 47 |
+
def __init__(self, achievement):
|
| 48 |
+
self.achievement = achievement
|
| 49 |
+
|
| 50 |
+
def test_with_mock_models():
|
| 51 |
+
"""Test PDF generation with mock model objects."""
|
| 52 |
+
log_info("Testing PDF generation with mock model structure...")
|
| 53 |
+
|
| 54 |
+
# Create mock data
|
| 55 |
+
work_experiences = [
|
| 56 |
+
MockWorkExperience("Software Engineer", "Tech Corp", 1, 2022, None, None, "Developing web applications"),
|
| 57 |
+
MockWorkExperience("Junior Developer", "StartupXYZ", 6, 2020, 12, 2021, "Built mobile apps")
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
projects = [
|
| 61 |
+
MockProject("E-commerce Platform", "Personal", 3, 2023, 5, 2023, "Full-stack development")
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
educations = [
|
| 65 |
+
MockEducation("BS Computer Science", "University", 9, 2016, 5, 2020, "Graduated with honors")
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
skills = [
|
| 69 |
+
MockSkill("Python"),
|
| 70 |
+
MockSkill("JavaScript"),
|
| 71 |
+
MockSkill("React"),
|
| 72 |
+
MockSkill("Node.js")
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
achievements = [
|
| 76 |
+
MockAchievement("Employee of the Year 2023"),
|
| 77 |
+
MockAchievement("Best Project Award")
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
# Test data conversion (same as in app.py)
|
| 81 |
+
import calendar
|
| 82 |
+
|
| 83 |
+
def format_date(month, year):
|
| 84 |
+
"""Format month and year as 'Month Year'"""
|
| 85 |
+
if month and year:
|
| 86 |
+
try:
|
| 87 |
+
month_name = calendar.month_name[int(month)]
|
| 88 |
+
return f"{month_name[:3]} {year}"
|
| 89 |
+
except:
|
| 90 |
+
return f"{month}/{year}"
|
| 91 |
+
return ""
|
| 92 |
+
|
| 93 |
+
# Prepare data for PDF generation
|
| 94 |
+
work_exp_list = []
|
| 95 |
+
for exp in work_experiences:
|
| 96 |
+
start_date = format_date(exp.start_month, exp.start_year)
|
| 97 |
+
end_date = "Present" if not exp.end_month or not exp.end_year else format_date(exp.end_month, exp.end_year)
|
| 98 |
+
|
| 99 |
+
work_exp_list.append({
|
| 100 |
+
'title': exp.title,
|
| 101 |
+
'organization': exp.organization,
|
| 102 |
+
'start_date': start_date,
|
| 103 |
+
'end_date': end_date,
|
| 104 |
+
'remarks': exp.remarks or ''
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
projects_list = []
|
| 108 |
+
for proj in projects:
|
| 109 |
+
start_date = format_date(proj.start_month, proj.start_year)
|
| 110 |
+
end_date = "Present" if not proj.end_month or not proj.end_year else format_date(proj.end_month, proj.end_year)
|
| 111 |
+
|
| 112 |
+
projects_list.append({
|
| 113 |
+
'title': proj.title,
|
| 114 |
+
'organization': proj.organization,
|
| 115 |
+
'start_date': start_date,
|
| 116 |
+
'end_date': end_date,
|
| 117 |
+
'remarks': proj.remarks or ''
|
| 118 |
+
})
|
| 119 |
+
|
| 120 |
+
education_list = []
|
| 121 |
+
for edu in educations:
|
| 122 |
+
start_date = format_date(edu.start_month, edu.start_year)
|
| 123 |
+
end_date = "Present" if not edu.end_month or not edu.end_year else format_date(edu.end_month, edu.end_year)
|
| 124 |
+
|
| 125 |
+
education_list.append({
|
| 126 |
+
'title': edu.title,
|
| 127 |
+
'organization': edu.organization,
|
| 128 |
+
'start_date': start_date,
|
| 129 |
+
'end_date': end_date,
|
| 130 |
+
'remarks': edu.remarks or ''
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
# Convert skills and achievements to comma-separated strings
|
| 134 |
+
skills_text = ', '.join([skill.skill for skill in skills]) if skills else ''
|
| 135 |
+
achievements_text = ', '.join([achievement.achievement for achievement in achievements]) if achievements else ''
|
| 136 |
+
|
| 137 |
+
# Create data dictionary
|
| 138 |
+
data = {
|
| 139 |
+
'name': 'John Doe',
|
| 140 |
+
'email': 'john@example.com',
|
| 141 |
+
'phone': '+1 (555) 123-4567',
|
| 142 |
+
'linkedin': 'johndoe',
|
| 143 |
+
'github': 'johndoe',
|
| 144 |
+
'website': 'https://johndoe.com',
|
| 145 |
+
'summary': 'Experienced software developer with expertise in full-stack development.',
|
| 146 |
+
'work_experience': work_exp_list,
|
| 147 |
+
'projects': projects_list,
|
| 148 |
+
'education': education_list,
|
| 149 |
+
'skills': skills_text,
|
| 150 |
+
'achievements': achievements_text,
|
| 151 |
+
'sections_order': ['work_experience', 'projects', 'education', 'skills', 'achievements']
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
# Test PDF generation
|
| 155 |
+
try:
|
| 156 |
+
log_info("Generating standard PDF...")
|
| 157 |
+
pdf_bytes = create_pdf_resume(data, "standard")
|
| 158 |
+
if pdf_bytes:
|
| 159 |
+
with open('test_mock_standard.pdf', 'wb') as f:
|
| 160 |
+
f.write(pdf_bytes)
|
| 161 |
+
log_info("✓ Standard PDF generated successfully: test_mock_standard.pdf")
|
| 162 |
+
else:
|
| 163 |
+
log_info("✗ Failed to generate standard PDF")
|
| 164 |
+
|
| 165 |
+
log_info("Generating modern PDF...")
|
| 166 |
+
pdf_bytes = create_pdf_resume(data, "modern")
|
| 167 |
+
if pdf_bytes:
|
| 168 |
+
with open('test_mock_modern.pdf', 'wb') as f:
|
| 169 |
+
f.write(pdf_bytes)
|
| 170 |
+
log_info("✓ Modern PDF generated successfully: test_mock_modern.pdf")
|
| 171 |
+
else:
|
| 172 |
+
log_info("✗ Failed to generate modern PDF")
|
| 173 |
+
|
| 174 |
+
except Exception as e:
|
| 175 |
+
log_error(f"Test failed: {str(e)}", e)
|
| 176 |
+
|
| 177 |
+
if __name__ == "__main__":
|
| 178 |
+
test_with_mock_models()
|
test_pdf_generation.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test script for PDF generation using xhtml2pdf.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pdf_generator import create_pdf_resume
|
| 7 |
+
from utils import log_info
|
| 8 |
+
|
| 9 |
+
# Test data
|
| 10 |
+
test_data = {
|
| 11 |
+
'name': 'John Doe',
|
| 12 |
+
'email': 'john.doe@example.com',
|
| 13 |
+
'phone': '+1 (555) 123-4567',
|
| 14 |
+
'linkedin': 'johndoe',
|
| 15 |
+
'github': 'johndoe',
|
| 16 |
+
'website': 'https://johndoe.com',
|
| 17 |
+
'summary': 'Experienced software developer with 5+ years of expertise in full-stack development, cloud technologies, and team leadership. Passionate about building scalable solutions and mentoring junior developers.',
|
| 18 |
+
'work_experience': [
|
| 19 |
+
{
|
| 20 |
+
'title': 'Senior Software Engineer',
|
| 21 |
+
'organization': 'Tech Corp',
|
| 22 |
+
'start_date': 'Jan 2022',
|
| 23 |
+
'end_date': 'Present',
|
| 24 |
+
'remarks': 'Leading development of microservices architecture serving 1M+ users. Mentoring team of 5 developers.'
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
'title': 'Software Developer',
|
| 28 |
+
'organization': 'StartupXYZ',
|
| 29 |
+
'start_date': 'Jun 2020',
|
| 30 |
+
'end_date': 'Dec 2021',
|
| 31 |
+
'remarks': 'Developed RESTful APIs and implemented CI/CD pipelines. Reduced deployment time by 60%.'
|
| 32 |
+
}
|
| 33 |
+
],
|
| 34 |
+
'projects': [
|
| 35 |
+
{
|
| 36 |
+
'title': 'E-commerce Platform',
|
| 37 |
+
'organization': 'Personal Project',
|
| 38 |
+
'start_date': 'Mar 2023',
|
| 39 |
+
'end_date': 'May 2023',
|
| 40 |
+
'remarks': 'Built full-stack e-commerce platform using React, Node.js, and PostgreSQL. Implemented payment processing and inventory management.'
|
| 41 |
+
}
|
| 42 |
+
],
|
| 43 |
+
'education': [
|
| 44 |
+
{
|
| 45 |
+
'title': 'Bachelor of Science in Computer Science',
|
| 46 |
+
'organization': 'University of Technology',
|
| 47 |
+
'start_date': 'Sep 2016',
|
| 48 |
+
'end_date': 'May 2020',
|
| 49 |
+
'remarks': 'Graduated Magna Cum Laude. President of Computer Science Club.'
|
| 50 |
+
}
|
| 51 |
+
],
|
| 52 |
+
'skills': 'Python, JavaScript, React, Node.js, PostgreSQL, MongoDB, AWS, Docker, Kubernetes, Git, CI/CD',
|
| 53 |
+
'achievements': 'Employee of the Year 2023, Best Project Award at University Hackathon 2019',
|
| 54 |
+
'sections_order': ['work_experience', 'projects', 'education', 'skills', 'achievements']
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
def test_pdf_generation():
|
| 58 |
+
"""Test PDF generation for both templates."""
|
| 59 |
+
try:
|
| 60 |
+
log_info("Testing PDF generation...")
|
| 61 |
+
|
| 62 |
+
# Test standard template
|
| 63 |
+
log_info("Generating standard template PDF...")
|
| 64 |
+
pdf_bytes_standard = create_pdf_resume(test_data, "standard")
|
| 65 |
+
|
| 66 |
+
if pdf_bytes_standard:
|
| 67 |
+
with open('test_resume_standard.pdf', 'wb') as f:
|
| 68 |
+
f.write(pdf_bytes_standard)
|
| 69 |
+
log_info("Standard PDF generated successfully: test_resume_standard.pdf")
|
| 70 |
+
else:
|
| 71 |
+
log_info("Failed to generate standard PDF")
|
| 72 |
+
|
| 73 |
+
# Test modern template
|
| 74 |
+
log_info("Generating modern template PDF...")
|
| 75 |
+
pdf_bytes_modern = create_pdf_resume(test_data, "modern")
|
| 76 |
+
|
| 77 |
+
if pdf_bytes_modern:
|
| 78 |
+
with open('test_resume_modern.pdf', 'wb') as f:
|
| 79 |
+
f.write(pdf_bytes_modern)
|
| 80 |
+
log_info("Modern PDF generated successfully: test_resume_modern.pdf")
|
| 81 |
+
else:
|
| 82 |
+
log_info("Failed to generate modern PDF")
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
log_error(f"Test failed: {str(e)}", e)
|
| 86 |
+
|
| 87 |
+
if __name__ == "__main__":
|
| 88 |
+
test_pdf_generation()
|
test_user_data.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test PDF generation with actual user data.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from models import db, User, Introduction, ProfileSummary, WorkExperience, Project, Education, Skill, Achievement, ProfileSectionOrder
|
| 7 |
+
from app import app
|
| 8 |
+
from pdf_generator import create_pdf_resume
|
| 9 |
+
import calendar
|
| 10 |
+
|
| 11 |
+
def test_with_actual_data():
|
| 12 |
+
"""Test PDF generation with actual user data."""
|
| 13 |
+
|
| 14 |
+
with app.app_context():
|
| 15 |
+
# Get user with profile
|
| 16 |
+
user = User.query.filter_by(email='test1@example.com').first()
|
| 17 |
+
if not user:
|
| 18 |
+
print("User test1@example.com not found")
|
| 19 |
+
return
|
| 20 |
+
|
| 21 |
+
print(f"Testing PDF generation for user: {user.email}")
|
| 22 |
+
|
| 23 |
+
# Get profile data
|
| 24 |
+
intro = Introduction.query.filter_by(user_id=user.id).first()
|
| 25 |
+
summary = ProfileSummary.query.filter_by(user_id=user.id).first()
|
| 26 |
+
work_experiences = WorkExperience.query.filter_by(user_id=user.id).order_by(WorkExperience.order).all()
|
| 27 |
+
projects = Project.query.filter_by(user_id=user.id).order_by(Project.order).all()
|
| 28 |
+
educations = Education.query.filter_by(user_id=user.id).order_by(Education.order).all()
|
| 29 |
+
skills = Skill.query.filter_by(user_id=user.id).order_by(Skill.order).all()
|
| 30 |
+
achievements = Achievement.query.filter_by(user_id=user.id).order_by(Achievement.order).all()
|
| 31 |
+
|
| 32 |
+
# Get section order
|
| 33 |
+
section_order_obj = ProfileSectionOrder.query.filter_by(user_id=user.id).first()
|
| 34 |
+
section_order = section_order_obj.section_order if section_order_obj else [
|
| 35 |
+
'introduction', 'profile_summary', 'work_experience',
|
| 36 |
+
'projects', 'education', 'skills', 'achievements'
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
def format_date(month, year):
|
| 40 |
+
"""Format month and year as 'Month Year'"""
|
| 41 |
+
if month and year:
|
| 42 |
+
try:
|
| 43 |
+
month_name = calendar.month_name[int(month)]
|
| 44 |
+
return f"{month_name[:3]} {year}"
|
| 45 |
+
except:
|
| 46 |
+
return f"{month}/{year}"
|
| 47 |
+
return ""
|
| 48 |
+
|
| 49 |
+
# Prepare data for PDF generation
|
| 50 |
+
work_exp_list = []
|
| 51 |
+
for exp in work_experiences:
|
| 52 |
+
start_date = format_date(exp.start_month, exp.start_year)
|
| 53 |
+
end_date = "Present" if not exp.end_month or not exp.end_year else format_date(exp.end_month, exp.end_year)
|
| 54 |
+
|
| 55 |
+
work_exp_list.append({
|
| 56 |
+
'title': exp.title,
|
| 57 |
+
'organization': exp.organization,
|
| 58 |
+
'start_date': start_date,
|
| 59 |
+
'end_date': end_date,
|
| 60 |
+
'remarks': exp.remarks or ''
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
projects_list = []
|
| 64 |
+
for proj in projects:
|
| 65 |
+
start_date = format_date(proj.start_month, proj.start_year)
|
| 66 |
+
end_date = "Present" if not proj.end_month or not proj.end_year else format_date(proj.end_month, proj.end_year)
|
| 67 |
+
|
| 68 |
+
projects_list.append({
|
| 69 |
+
'title': proj.title,
|
| 70 |
+
'organization': proj.organization,
|
| 71 |
+
'start_date': start_date,
|
| 72 |
+
'end_date': end_date,
|
| 73 |
+
'remarks': proj.remarks or ''
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
education_list = []
|
| 77 |
+
for edu in educations:
|
| 78 |
+
start_date = format_date(edu.start_month, edu.start_year)
|
| 79 |
+
end_date = "Present" if not edu.end_month or not edu.end_year else format_date(edu.end_month, edu.end_year)
|
| 80 |
+
|
| 81 |
+
education_list.append({
|
| 82 |
+
'title': edu.title,
|
| 83 |
+
'organization': edu.organization,
|
| 84 |
+
'start_date': start_date,
|
| 85 |
+
'end_date': end_date,
|
| 86 |
+
'remarks': edu.remarks or ''
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
# Convert skills and achievements to comma-separated strings
|
| 90 |
+
skills_text = ', '.join([skill.skill for skill in skills]) if skills else ''
|
| 91 |
+
achievements_text = ', '.join([achievement.achievement for achievement in achievements]) if achievements else ''
|
| 92 |
+
|
| 93 |
+
# Create data dictionary
|
| 94 |
+
data = {
|
| 95 |
+
'name': intro.name,
|
| 96 |
+
'email': intro.email,
|
| 97 |
+
'phone': intro.phone,
|
| 98 |
+
'linkedin': intro.linkedin,
|
| 99 |
+
'github': intro.github,
|
| 100 |
+
'website': intro.website,
|
| 101 |
+
'summary': summary.summary if summary else '',
|
| 102 |
+
'work_experience': work_exp_list,
|
| 103 |
+
'projects': projects_list,
|
| 104 |
+
'education': education_list,
|
| 105 |
+
'skills': skills_text,
|
| 106 |
+
'achievements': achievements_text,
|
| 107 |
+
'sections_order': section_order
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
print("Data prepared:")
|
| 111 |
+
print(f" Name: {data['name']}")
|
| 112 |
+
print(f" Work experiences: {len(work_exp_list)}")
|
| 113 |
+
print(f" Projects: {len(projects_list)}")
|
| 114 |
+
print(f" Education: {len(education_list)}")
|
| 115 |
+
print(f" Skills: {skills_text}")
|
| 116 |
+
print(f" Achievements: {achievements_text}")
|
| 117 |
+
|
| 118 |
+
# Test PDF generation
|
| 119 |
+
try:
|
| 120 |
+
print("\nGenerating standard PDF...")
|
| 121 |
+
pdf_bytes = create_pdf_resume(data, "standard")
|
| 122 |
+
if pdf_bytes:
|
| 123 |
+
with open('test_user_standard.pdf', 'wb') as f:
|
| 124 |
+
f.write(pdf_bytes)
|
| 125 |
+
print("SUCCESS: Standard PDF generated successfully: test_user_standard.pdf")
|
| 126 |
+
else:
|
| 127 |
+
print("✗ Failed to generate standard PDF")
|
| 128 |
+
|
| 129 |
+
print("\nGenerating modern PDF...")
|
| 130 |
+
pdf_bytes = create_pdf_resume(data, "modern")
|
| 131 |
+
if pdf_bytes:
|
| 132 |
+
with open('test_user_modern.pdf', 'wb') as f:
|
| 133 |
+
f.write(pdf_bytes)
|
| 134 |
+
print("SUCCESS: Modern PDF generated successfully: test_user_modern.pdf")
|
| 135 |
+
else:
|
| 136 |
+
print("FAILED: Failed to generate modern PDF")
|
| 137 |
+
|
| 138 |
+
except Exception as e:
|
| 139 |
+
print(f"Error: {str(e)}")
|
| 140 |
+
import traceback
|
| 141 |
+
traceback.print_exc()
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
test_with_actual_data()
|
test_weasyprint.py.old
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
| 5 |
+
|
| 6 |
+
from app import app
|
| 7 |
+
|
| 8 |
+
def test_weasyprint():
|
| 9 |
+
with app.app_context():
|
| 10 |
+
try:
|
| 11 |
+
import weasyprint
|
| 12 |
+
from io import BytesIO
|
| 13 |
+
|
| 14 |
+
# Simple HTML test
|
| 15 |
+
html_content = """
|
| 16 |
+
<!DOCTYPE html>
|
| 17 |
+
<html>
|
| 18 |
+
<head>
|
| 19 |
+
<style>
|
| 20 |
+
body { font-family: Arial; padding: 20px; }
|
| 21 |
+
h1 { color: #333; }
|
| 22 |
+
</style>
|
| 23 |
+
</head>
|
| 24 |
+
<body>
|
| 25 |
+
<h1>Test PDF Generation</h1>
|
| 26 |
+
<p>This is a test of WeasyPrint PDF generation.</p>
|
| 27 |
+
</body>
|
| 28 |
+
</html>
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
# Generate PDF
|
| 32 |
+
pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
|
| 33 |
+
|
| 34 |
+
# Save test file
|
| 35 |
+
with open('test_weasyprint.pdf', 'wb') as f:
|
| 36 |
+
f.write(pdf_bytes)
|
| 37 |
+
|
| 38 |
+
print("WeasyPrint test successful!")
|
| 39 |
+
print(f"Generated PDF with {len(pdf_bytes)} bytes")
|
| 40 |
+
return True
|
| 41 |
+
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print(f"WeasyPrint test failed: {e}")
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
test_weasyprint()
|
utils.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility functions for the application.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import traceback
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
# Configure logging
|
| 10 |
+
logging.basicConfig(
|
| 11 |
+
level=logging.INFO,
|
| 12 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 13 |
+
handlers=[
|
| 14 |
+
logging.FileHandler('app.log'),
|
| 15 |
+
logging.StreamHandler()
|
| 16 |
+
]
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def log_error(message, exception=None):
|
| 23 |
+
"""Log error message with optional exception details."""
|
| 24 |
+
if exception:
|
| 25 |
+
logger.error(f"{message}: {str(exception)}")
|
| 26 |
+
logger.error(traceback.format_exc())
|
| 27 |
+
else:
|
| 28 |
+
logger.error(message)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def log_info(message):
|
| 32 |
+
"""Log info message."""
|
| 33 |
+
logger.info(message)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_timestamp():
|
| 37 |
+
"""Get current timestamp as string."""
|
| 38 |
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def sanitize_filename(filename):
|
| 42 |
+
"""Sanitize filename for safe file operations."""
|
| 43 |
+
# Remove or replace unsafe characters
|
| 44 |
+
unsafe_chars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']
|
| 45 |
+
for char in unsafe_chars:
|
| 46 |
+
filename = filename.replace(char, '_')
|
| 47 |
+
return filename.strip()
|