diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..033963b --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DB_HOST= +DB_PORT=3306 + +DB_NAME= + +DB_USER= +DB_PASSWORD= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb67acd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..0913d01 --- /dev/null +++ b/app/database.py @@ -0,0 +1,38 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import declarative_base + +from dotenv import load_dotenv +import os + +load_dotenv() + +DATABASE_URL = ( + f"mysql+pymysql://" + f"{os.getenv('DB_USER')}:" + f"{os.getenv('DB_PASSWORD')}@" + f"{os.getenv('DB_HOST')}:" + f"{os.getenv('DB_PORT')}/" + f"{os.getenv('DB_NAME')}" +) + +engine = create_engine( + DATABASE_URL, + pool_pre_ping=True +) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..bbc5cc3 --- /dev/null +++ b/app/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI + +from app.routers import projects, experiences, skills + +app = FastAPI() + +app.include_router(projects.router) +app.include_router(experiences.router) +app.include_router(skills.router) diff --git a/app/models/experience.py b/app/models/experience.py new file mode 100644 index 0000000..07f3a43 --- /dev/null +++ b/app/models/experience.py @@ -0,0 +1,45 @@ +from sqlalchemy import ( + Column, + Integer, + String +) + +from sqlalchemy.orm import relationship + +from app.database import Base + + +class Experience(Base): + __tablename__ = "experiences" + + id = Column( + Integer, + primary_key=True, + index=True + ) + + company = Column( + String(255), + nullable=False + ) + + location = Column( + String(255), + nullable=False + ) + + duration = Column( + String(255), + nullable=False + ) + + role = Column( + String(255), + nullable=False + ) + + tasks = relationship( + "ExperienceTask", + back_populates="experience", + cascade="all, delete-orphan" + ) \ No newline at end of file diff --git a/app/models/experience_task.py b/app/models/experience_task.py new file mode 100644 index 0000000..ee854db --- /dev/null +++ b/app/models/experience_task.py @@ -0,0 +1,39 @@ +from sqlalchemy import ( + Column, + Integer, + String, + ForeignKey +) + +from sqlalchemy.orm import relationship + +from app.database import Base + + +class ExperienceTask(Base): + __tablename__ = "experience_tasks" + + id = Column( + Integer, + primary_key=True, + index=True + ) + + description = Column( + String(255), + nullable=False + ) + + experience_id = Column( + Integer, + ForeignKey( + "experiences.id", + ondelete="CASCADE" + ), + nullable=False + ) + + experience = relationship( + "Experience", + back_populates="tasks" + ) \ No newline at end of file diff --git a/app/models/project.py b/app/models/project.py new file mode 100644 index 0000000..43a7937 --- /dev/null +++ b/app/models/project.py @@ -0,0 +1,70 @@ +from sqlalchemy import ( + Column, + Integer, + String, + Text, + Boolean +) + +from sqlalchemy.orm import relationship + +from app.database import Base +from app.models.project_skill import project_skills + + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + + title = Column( + String(255), + nullable=False + ) + + nb_image = Column( + Integer, + default=1 + ) + + short_description = Column( + Text, + nullable=False + ) + + image_name = Column( + String(255), + nullable=False, + default="empty" + ) + + school = Column( + Boolean, + nullable=False + ) + + long_description = Column( + Text, + nullable=False + ) + + repo_link = Column( + String(255), + nullable=True + ) + + beginning_year = Column( + Integer, + nullable=False + ) + + end_year = Column( + Integer, + nullable=True + ) + + skills = relationship( + "Skill", + secondary=project_skills, + back_populates="projects" + ) \ No newline at end of file diff --git a/app/models/project_skill.py b/app/models/project_skill.py new file mode 100644 index 0000000..c048a15 --- /dev/null +++ b/app/models/project_skill.py @@ -0,0 +1,22 @@ +from sqlalchemy import Table, Column, Integer, ForeignKey + +from app.database import Base + +project_skills = Table( + "project_skills", + Base.metadata, + + Column( + "project_id", + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), + primary_key=True + ), + + Column( + "skill_id", + Integer, + ForeignKey("skills.id", ondelete="CASCADE"), + primary_key=True + ) +) \ No newline at end of file diff --git a/app/models/skill.py b/app/models/skill.py new file mode 100644 index 0000000..5bd83c7 --- /dev/null +++ b/app/models/skill.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship + +from app.database import Base +from app.models.project_skill import project_skills + + +class Skill(Base): + __tablename__ = "skills" + + id = Column(Integer, primary_key=True, index=True) + + name = Column( + String(255), + nullable=False, + unique=True + ) + + type = Column( + String(255), + nullable=False + ) + + projects = relationship( + "Project", + secondary=project_skills, + back_populates="skills" + ) \ No newline at end of file diff --git a/app/routers/experiences.py b/app/routers/experiences.py new file mode 100644 index 0000000..98984c9 --- /dev/null +++ b/app/routers/experiences.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload + +from app.database import get_db +from app.models.experience import Experience +from app.schemas.experience import ExperienceResponse + +router = APIRouter(prefix="/experiences", tags=["Experiences"]) + + +@router.get("/", response_model=list[ExperienceResponse]) +def get_experiences(db: Session = Depends(get_db)): + return ( + db.query(Experience) + .options(joinedload(Experience.tasks)) + .all() + ) diff --git a/app/routers/projects.py b/app/routers/projects.py new file mode 100644 index 0000000..424a7c5 --- /dev/null +++ b/app/routers/projects.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload + +from app.database import get_db +from app.models.project import Project +from app.schemas.project import ProjectCard, ProjectPage + +router = APIRouter(prefix="/projects", tags=["Projects"]) + + +@router.get("/", response_model=list[ProjectCard]) +def get_projects(db: Session = Depends(get_db)): + return ( + db.query(Project) + .options(joinedload(Project.skills)) + .all() + ) + + +@router.get("/{project_id}", response_model=ProjectPage) +def get_project(project_id: int, db: Session = Depends(get_db)): + project = ( + db.query(Project) + .options(joinedload(Project.skills)) + .filter(Project.id == project_id) + .first() + ) + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + return project \ No newline at end of file diff --git a/app/routers/skills.py b/app/routers/skills.py new file mode 100644 index 0000000..4f3393d --- /dev/null +++ b/app/routers/skills.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.skill import Skill +from app.schemas.skill import SkillResponse + +router = APIRouter(prefix="/skills", tags=["Skills"]) + + +@router.get("/", response_model=list[SkillResponse]) +def get_skills(db: Session = Depends(get_db)): + return db.query(Skill).all() \ No newline at end of file diff --git a/app/schemas/experience.py b/app/schemas/experience.py new file mode 100644 index 0000000..68fcdc2 --- /dev/null +++ b/app/schemas/experience.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel + +class ExperienceTaskResponse(BaseModel): + id: int + description: str + + class Config: + from_attributes = True + +class ExperienceResponse(BaseModel): + id: int + company: str + location: str + duration: str + role: str + tasks: list[ExperienceTaskResponse] + + class Config: + from_attributes = Trues + diff --git a/app/schemas/project.py b/app/schemas/project.py new file mode 100644 index 0000000..cd56f1d --- /dev/null +++ b/app/schemas/project.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel +from app.schemas.skill import ProjectSkill + + +class ProjectCard(BaseModel): + id: int + title: str + short_description: str + image_name: str + school: bool + beginning_year: int + end_year: int | None + skills: list[ProjectSkill] + + class Config: + from_attributes = True + + +class ProjectPage(BaseModel): + id: int + title: str + short_description: str + long_description: str + repo_link: str | None + nb_image: int + image_name: str + school: bool + beginning_year: int + end_year: int | None + skills: list[ProjectSkill] + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/skill.py b/app/schemas/skill.py new file mode 100644 index 0000000..501959f --- /dev/null +++ b/app/schemas/skill.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +class SkillResponse(BaseModel): + id: int + name: str + type: str + + class Config: + from_attributes = True + + +class ProjectSkill(BaseModel): + id: int + name: str + + class Config: + from_attributes = True \ No newline at end of file diff --git a/database.py b/database.py deleted file mode 100644 index e9d5883..0000000 --- a/database.py +++ /dev/null @@ -1,20 +0,0 @@ -# database.py (sans générateur) -import os -import MySQLdb -from dotenv import load_dotenv - -load_dotenv() - -def get_db_connection(): - try: - conn = MySQLdb.connect( - user=os.getenv("DB_USER"), - passwd=os.getenv("DB_PASS"), - host=os.getenv("DB_HOST"), - db=os.getenv("DB_NAME"), - charset='utf8mb4' - ) - return conn - except Exception as e: - print(f"ERREUR CRITIQUE DE CONNEXION DB : {e}") - return None \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fc8fbec --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3.8" + +services: + backend: + build: . + container_name: portfolio-backend + restart: unless-stopped + env_file: + - .env + ports: + - "8000:8000" + volumes: + - .:/app \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 8fbc98f..0000000 --- a/main.py +++ /dev/null @@ -1,48 +0,0 @@ -import os -from fastapi import FastAPI, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from routers import skills, experiences, experienceTasks, shortProjects, longProjects - -app = FastAPI() - -origins = [ - "http://localhost", - "http://localhost:5173", -] -app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -@app.get("/") -def read_root(): - return {"status": "API Portfolio fonctionnelle !"} - -app.include_router( - skills.router, - prefix="/api/skills", - tags=["Skills"], -) -app.include_router( -experiences.router, - prefix="/api/experiences", - tags=["Experiences"] -) -app.include_router( - experienceTasks.router, - prefix="/api/experienceTasks", - tags=["ExperienceTasks"] -) -app.include_router( - shortProjects.router, - prefix="/api/shortProjects", - tags=["ShortProjects"] -) -app.include_router( - longProjects.router, - prefix="/api/longProjects", - tags=["LongProjects"] -) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 83cca42..7823962 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -fastapi -uvicorn -mysqlclient +fastapi +uvicorn[standard] +sqlalchemy +pymysql python-dotenv \ No newline at end of file diff --git a/routers/experienceTasks.py b/routers/experienceTasks.py deleted file mode 100644 index 716d8cd..0000000 --- a/routers/experienceTasks.py +++ /dev/null @@ -1,24 +0,0 @@ -# routers/skills.py (sans générateur) -from fastapi import APIRouter, HTTPException -import MySQLdb -from database import get_db_connection - -router = APIRouter() - - -@router.get("/") -def read_experienceTasks(): - conn = get_db_connection() - if not conn: - raise HTTPException(status_code=503, detail="Service de base de données indisponible.") - - try: - with conn.cursor(MySQLdb.cursors.DictCursor) as cursor: - sql = "SELECT * FROM experience_tasks;" - cursor.execute(sql) - result = cursor.fetchall() - return {"data": result} - except MySQLdb.Error as e: - raise HTTPException(status_code=500, detail=f"Erreur lors de la requête: {e}") - finally: - conn.close() \ No newline at end of file diff --git a/routers/experiences.py b/routers/experiences.py deleted file mode 100644 index 551cb27..0000000 --- a/routers/experiences.py +++ /dev/null @@ -1,24 +0,0 @@ -# routers/skills.py (sans générateur) -from fastapi import APIRouter, HTTPException -import MySQLdb -from database import get_db_connection - -router = APIRouter() - - -@router.get("/") -def read_experiences(): - conn = get_db_connection() - if not conn: - raise HTTPException(status_code=503, detail="Service de base de données indisponible.") - - try: - with conn.cursor(MySQLdb.cursors.DictCursor) as cursor: - sql = "SELECT * FROM experiences;" - cursor.execute(sql) - result = cursor.fetchall() - return {"data": result} - except MySQLdb.Error as e: - raise HTTPException(status_code=500, detail=f"Erreur lors de la requête: {e}") - finally: - conn.close() \ No newline at end of file diff --git a/routers/longProjects.py b/routers/longProjects.py deleted file mode 100644 index 718f271..0000000 --- a/routers/longProjects.py +++ /dev/null @@ -1,53 +0,0 @@ -from fastapi import APIRouter, HTTPException -import MySQLdb -from database import get_db_connection - -router = APIRouter() - -@router.get("/{project_id}") -def read_long_project(project_id: int): - conn = get_db_connection() - if not conn: - raise HTTPException(status_code=503, detail="Service de base de données indisponible.") - - try: - with conn.cursor(MySQLdb.cursors.DictCursor) as cursor: - sql = """ - SELECT - p.id, - p.title, - p.long_description, - p.short_description, - p.school, - p.repo_link, - p.nb_image, - p.image_name, - p.beginning_year, - p.end_year, - GROUP_CONCAT(DISTINCT s.name ORDER BY s.name SEPARATOR ', ') AS skills - FROM - projects p - LEFT JOIN - project_skills ps ON p.id = ps.project_id - LEFT JOIN - skills s ON ps.skill_id = s.id - WHERE p.id = %s - GROUP BY p.id; - """ - cursor.execute(sql, (project_id,)) - result = cursor.fetchone() - - if not result: - raise HTTPException(status_code=404, detail="Projet non trouvé.") - - if result['skills']: - result['skills'] = result['skills'].split(', ') - else: - result['skills'] = [] - - return result - - except MySQLdb.Error as e: - raise HTTPException(status_code=500, detail=f"Erreur lors de la requête: {e}") - finally: - conn.close() diff --git a/routers/shortProjects.py b/routers/shortProjects.py deleted file mode 100644 index be5aead..0000000 --- a/routers/shortProjects.py +++ /dev/null @@ -1,48 +0,0 @@ -from fastapi import APIRouter, HTTPException -import MySQLdb -from database import get_db_connection - -router = APIRouter() - - -@router.get("/") -def read_shortProjects(): - conn = get_db_connection() - if not conn: - raise HTTPException(status_code=503, detail="Service de base de données indisponible.") - - try: - with conn.cursor(MySQLdb.cursors.DictCursor) as cursor: - sql = """ - SELECT - p.id, - p.title, - p.short_description, - p.school, - p.image_name, - p.beginning_year, - p.end_year, - GROUP_CONCAT(DISTINCT s.name ORDER BY s.name SEPARATOR ', ') AS skills - FROM - projects p - LEFT JOIN - project_skills ps ON p.id = ps.project_id - LEFT JOIN - skills s ON ps.skill_id = s.id - GROUP BY - p.id; - """ - cursor.execute(sql) - result = cursor.fetchall() - - for project in result: - if project['skills']: - project['skills'] = project['skills'].split(', ') - else: - project['skills'] = [] - - return {"data": result} - except MySQLdb.Error as e: - raise HTTPException(status_code=500, detail=f"Erreur lors de la requête: {e}") - finally: - conn.close() \ No newline at end of file diff --git a/routers/skills.py b/routers/skills.py deleted file mode 100644 index bf9c062..0000000 --- a/routers/skills.py +++ /dev/null @@ -1,23 +0,0 @@ -# routers/skills.py (sans générateur) -from fastapi import APIRouter, HTTPException -import MySQLdb -from database import get_db_connection - -router = APIRouter() - -@router.get("/") -def read_skills(): - conn = get_db_connection() - if not conn: - raise HTTPException(status_code=503, detail="Service de base de données indisponible.") - - try: - with conn.cursor(MySQLdb.cursors.DictCursor) as cursor: - sql = "SELECT * FROM skills;" - cursor.execute(sql) - result = cursor.fetchall() - return {"data": result} - except MySQLdb.Error as e: - raise HTTPException(status_code=500, detail=f"Erreur lors de la requête: {e}") - finally: - conn.close() \ No newline at end of file