From 9e5031a43f6b4d8c82b6d9ccdadaba35c146d0bc Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Wed, 4 Feb 2026 12:52:26 +0100 Subject: [PATCH 1/5] feat: (#60) Add unit tests for business rules --- .env.test | 2 + .github/workflows/ci.yml | 59 ++ requirements-dev.txt | 79 ++- requirements.txt | Bin 778 -> 1278 bytes tests/unit/domain/test_business_rules.py | 725 +++++++++++++++++++++++ 5 files changed, 850 insertions(+), 15 deletions(-) create mode 100644 .env.test create mode 100644 .github/workflows/ci.yml create mode 100644 tests/unit/domain/test_business_rules.py diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..2a574d1 --- /dev/null +++ b/.env.test @@ -0,0 +1,2 @@ +APP_ENV=test +MONGO_URL=mongodb://localhost:27017 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f9c570 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI Pipeline + +on: + push: + branches: ["main", "develop"] + pull_request: + branches: ["main", "develop"] + +jobs: + test: + runs-on: ubuntu-latest + + services: + mongo: + image: mongo:6 + ports: + - 27017:27017 + options: >- + --health-cmd="mongosh --eval 'db.runCommand({ ping: 1 })'" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Copy test environment variables + run: | + cp .env.test .env + + - name: Run linters (ruff) + run: ruff check . + + - name: Run unit tests + run: pytest tests/unit -vv + + - name: Run integration tests + env: + MONGO_URL: mongodb://localhost:27017 + run: pytest tests/integration -vv + + - name: Run E2E tests + env: + MONGO_URL: mongodb://localhost:27017 + run: pytest tests/e2e -vv + + - name: Build Docker image + run: docker build -t portfolio-backend . \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 420013e..a8593ee 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,18 +1,67 @@ +# ========================================== +# DEVELOPMENT DEPENDENCIES +# ========================================== # Incluir dependencias de producción -r requirements.txt -# Testing -pytest==7.4.3 -pytest-asyncio==0.21.1 -pytest-cov==4.1.0 -httpx==0.26.0 -pytest-mock==3.12.0 -httpx==0.26.0 - -# Code Quality -black==23.12.1 -ruff==0.1.9 -mypy==1.8.0 - -# Pre-commit hooks -pre-commit==3.6.0 \ No newline at end of file +# ========================================== +# TESTING +# ========================================== +pytest==8.3.4 +pytest-asyncio==0.24.0 +pytest-cov==6.0.0 +pytest-mock==3.14.0 +pytest-timeout==2.3.1 +pytest-xdist==3.6.1 # Parallel test execution + +# HTTP Testing +httpx==0.27.2 + +# Faker for test data generation +faker==33.1.0 + +# ========================================== +# CODE QUALITY & LINTING +# ========================================== +# Formatter +black==24.10.0 +isort==5.13.2 + +# Linter +ruff==0.8.4 + +# Type Checking +mypy==1.13.0 + +# Additional type stubs +types-python-dateutil==2.9.0.20241206 + +# ========================================== +# PRE-COMMIT HOOKS +# ========================================== +pre-commit==4.0.1 + +# ========================================== +# DEVELOPMENT TOOLS +# ========================================== +# IPython for better REPL +ipython==8.31.0 + +# Debugging +ipdb==0.13.13 + +# Documentation +mkdocs==1.6.1 +mkdocs-material==9.5.48 + +# ========================================== +# SECURITY & VULNERABILITIES +# ========================================== +safety==3.2.11 +bandit==1.8.0 + +# ========================================== +# PERFORMANCE & PROFILING +# ========================================== +pytest-benchmark==5.1.0 +memory-profiler==0.61.0 diff --git a/requirements.txt b/requirements.txt index 618614824a7971e64ef2325f0935a0e13607e010..1beab09d494a84234b365c9e4b849b114327674b 100644 GIT binary patch literal 1278 zcmZ`(?QViV5ZvEQ`Y4!!AFV(74t)bd5tUTH02+Duwlg~(A88tM7`fS*+1WdPzEi94 zsjagVE4RHxShZ#Lj?arNe68%+o`Cp^?X0&8>e>PS>NNr1TLCt5t?)j!#Gl;#v@;kp z`|{nyHoikW?q=X@;HaqPVdgv^SO!AR?Z2_jpZeXF_9^aia%a>GzKVYxLZVF!cW`^v0z)JRKGcgj_Fa0bpv^iZ5#Cpx6MJNyM@MiLL`l1Z=GYbAbCRkr zeagM<;OP!j?#XeoOfa~VQ%>&!51ecYr2?3s#~DkF2&T^bb6^7p(^`5j7i3M$!LumQ zS%FNX#u=^$cSK&HafHjjGgaz+VimY}R*29kGw+7^^Ng_*)-toPKI}$FuZ3m>SK$tFLgU1z zCttT-lf5}JvFcb8R@}TdaHv@e4{xyo>+< delta 76 zcmeyz*~K=YX5xXQ$w7>ElP@qzOy*&dnf!#QWAX%Mjma!5YLk6fHUaqpO3@6d47m)6 a44Di$46zKhKxo9E$6yF1O&NF@xEKJxSrdBz diff --git a/tests/unit/domain/test_business_rules.py b/tests/unit/domain/test_business_rules.py new file mode 100644 index 0000000..884dee7 --- /dev/null +++ b/tests/unit/domain/test_business_rules.py @@ -0,0 +1,725 @@ +""" +Unit tests for Business Rules Validation. + +This test suite validates that all business rules defined in the domain +layer are properly implemented and enforced. + +Business Rules Covered: +- Profile rules (RB-P01 to RB-P05) +- WorkExperience rules (RB-W01 to RB-W05) +- Skill rules (RB-S01 to RB-S04) +- Education rules (RB-E01 to RB-E05) +- ContactMessage rules (RB-CM01 to RB-CM03) +- Value Object rules (VR-E01, VR-P01, VR-SL01, VR-DR02) +- Immutability constraints +""" + +import pytest +from datetime import datetime, timedelta + +from app.domain.entities.profile import Profile +from app.domain.entities.work_experience import WorkExperience +from app.domain.entities.skill import Skill +from app.domain.entities.education import Education +from app.domain.entities.contact_message import ContactMessage +from app.domain.value_objects.email import Email +from app.domain.value_objects.phone import Phone +from app.domain.value_objects.skill_level import SkillLevel +from app.domain.value_objects.date_range import DateRange +from app.domain.exceptions import ( + EmptyFieldError, + InvalidLengthError, + InvalidEmailError, + InvalidPhoneError, + InvalidURLError, + InvalidDateRangeError, + InvalidSkillLevelError, + InvalidRoleError, + InvalidNameError, + InvalidInstitutionError, + InvalidCompanyError, + InvalidCategoryError, +) + + +# ========================================== +# PROFILE BUSINESS RULES TESTS +# ========================================== + +class TestProfileBusinessRules: + """Tests for Profile entity business rules.""" + + def test_rb_p01_name_is_required(self): + """RB-P01: Profile name is required.""" + with pytest.raises(EmptyFieldError): + Profile.create(name="", headline="Test Headline") + + def test_rb_p01_name_not_whitespace_only(self): + """RB-P01: Profile name cannot be whitespace only.""" + with pytest.raises(EmptyFieldError): + Profile.create(name=" ", headline="Test Headline") + + def test_rb_p02_headline_is_required(self): + """RB-P02: Profile headline is required.""" + with pytest.raises(EmptyFieldError): + Profile.create(name="John Doe", headline="") + + def test_rb_p02_headline_not_whitespace_only(self): + """RB-P02: Profile headline cannot be whitespace only.""" + with pytest.raises(EmptyFieldError): + Profile.create(name="John Doe", headline=" ") + + def test_rb_p03_bio_max_length_enforced(self): + """RB-P03: Profile bio has maximum length of 1000 characters.""" + long_bio = "x" * 1001 + + with pytest.raises(InvalidLengthError): + Profile.create( + name="John Doe", + headline="Test Headline", + bio=long_bio + ) + + def test_rb_p03_bio_max_length_accepted(self): + """RB-P03: Profile bio at exactly max length is accepted.""" + max_bio = "x" * 1000 + + profile = Profile.create( + name="John Doe", + headline="Test Headline", + bio=max_bio + ) + + assert len(profile.bio) == 1000 + + def test_rb_p05_avatar_url_must_be_valid(self): + """RB-P05: Avatar URL must be valid format.""" + with pytest.raises(InvalidURLError): + Profile.create( + name="John Doe", + headline="Test Headline", + avatar_url="not-a-url" + ) + + def test_rb_p05_avatar_url_valid_accepted(self): + """RB-P05: Valid avatar URL is accepted.""" + profile = Profile.create( + name="John Doe", + headline="Test Headline", + avatar_url="https://example.com/avatar.jpg" + ) + + assert profile.avatar_url == "https://example.com/avatar.jpg" + + +# ========================================== +# WORK EXPERIENCE BUSINESS RULES TESTS +# ========================================== + +class TestWorkExperienceBusinessRules: + """Tests for WorkExperience entity business rules.""" + + def test_rb_w01_role_is_required(self, profile_id, today): + """RB-W01: Work experience role is required.""" + with pytest.raises((EmptyFieldError, InvalidRoleError)): + WorkExperience.create( + profile_id=profile_id, + role="", + company="Test Company", + start_date=today, + order_index=0 + ) + + def test_rb_w01_role_not_whitespace_only(self, profile_id, today): + """RB-W01: Work experience role cannot be whitespace only.""" + with pytest.raises((EmptyFieldError, InvalidRoleError)): + WorkExperience.create( + profile_id=profile_id, + role=" ", + company="Test Company", + start_date=today, + order_index=0 + ) + + def test_rb_w02_company_is_required(self, profile_id, today): + """RB-W02: Company name is required.""" + with pytest.raises((EmptyFieldError, InvalidCompanyError)): + WorkExperience.create( + profile_id=profile_id, + role="Software Engineer", + company="", + start_date=today, + order_index=0 + ) + + def test_rb_w05_end_date_must_be_after_start_date(self, profile_id, yesterday, today): + """RB-W05: End date must be after start date.""" + with pytest.raises(InvalidDateRangeError): + WorkExperience.create( + profile_id=profile_id, + role="Software Engineer", + company="Test Company", + start_date=today, + end_date=yesterday, + order_index=0 + ) + + def test_rb_w05_end_date_cannot_equal_start_date(self, profile_id, today): + """RB-W05: End date cannot equal start date.""" + with pytest.raises(InvalidDateRangeError): + WorkExperience.create( + profile_id=profile_id, + role="Software Engineer", + company="Test Company", + start_date=today, + end_date=today, + order_index=0 + ) + + def test_rb_w05_valid_date_range_accepted(self, profile_id, yesterday, tomorrow): + """RB-W05: Valid date range is accepted.""" + experience = WorkExperience.create( + profile_id=profile_id, + role="Software Engineer", + company="Test Company", + start_date=yesterday, + end_date=tomorrow, + order_index=0 + ) + + assert experience.start_date == yesterday + assert experience.end_date == tomorrow + + +# ========================================== +# SKILL BUSINESS RULES TESTS +# ========================================== + +class TestSkillBusinessRules: + """Tests for Skill entity business rules.""" + + def test_rb_s01_name_is_required(self, profile_id): + """RB-S01: Skill name is required.""" + with pytest.raises((EmptyFieldError, InvalidNameError)): + Skill.create( + profile_id=profile_id, + name="", + category="backend", + order_index=0 + ) + + def test_rb_s01_name_not_whitespace_only(self, profile_id): + """RB-S01: Skill name cannot be whitespace only.""" + with pytest.raises((EmptyFieldError, InvalidNameError)): + Skill.create( + profile_id=profile_id, + name=" ", + category="backend", + order_index=0 + ) + + def test_rb_s02_category_is_required(self, profile_id): + """RB-S02: Skill category is required.""" + with pytest.raises((EmptyFieldError, InvalidCategoryError)): + Skill.create( + profile_id=profile_id, + name="Python", + category="", + order_index=0 + ) + + def test_rb_s04_level_must_be_valid(self, profile_id): + """RB-S04: Skill level must be one of: basic, intermediate, advanced, expert.""" + with pytest.raises(InvalidSkillLevelError): + Skill.create( + profile_id=profile_id, + name="Python", + category="backend", + level="invalid_level", + order_index=0 + ) + + def test_rb_s04_valid_levels_accepted(self, profile_id): + """RB-S04: All valid skill levels are accepted.""" + valid_levels = ["basic", "intermediate", "advanced", "expert"] + + for level in valid_levels: + skill = Skill.create( + profile_id=profile_id, + name=f"Python-{level}", + category="backend", + level=level, + order_index=0 + ) + # Skill stores level as string directly + assert skill.level == level + + +# ========================================== +# EDUCATION BUSINESS RULES TESTS +# ========================================== + +class TestEducationBusinessRules: + """Tests for Education entity business rules.""" + + def test_rb_e01_institution_is_required(self, profile_id, today): + """RB-E01: Institution name is required.""" + with pytest.raises((EmptyFieldError, InvalidInstitutionError)): + Education.create( + profile_id=profile_id, + institution="", + degree="Bachelor of Science", + field="Computer Science", + start_date=today, + order_index=0 + ) + + def test_rb_e01_institution_not_whitespace_only(self, profile_id, today): + """RB-E01: Institution name cannot be whitespace only.""" + with pytest.raises((EmptyFieldError, InvalidInstitutionError)): + Education.create( + profile_id=profile_id, + institution=" ", + degree="Bachelor of Science", + field="Computer Science", + start_date=today, + order_index=0 + ) + + def test_rb_e02_degree_is_required(self, profile_id, today): + """RB-E02: Degree is required.""" + with pytest.raises(EmptyFieldError): + Education.create( + profile_id=profile_id, + institution="Universidad Complutense", + degree="", + field="Computer Science", + start_date=today, + order_index=0 + ) + + def test_rb_e03_field_is_required(self, profile_id, today): + """RB-E03: Field of study is required.""" + with pytest.raises(EmptyFieldError): + Education.create( + profile_id=profile_id, + institution="Universidad Complutense", + degree="Bachelor of Science", + field="", + start_date=today, + order_index=0 + ) + + def test_rb_e05_end_date_must_be_after_start_date(self, profile_id, yesterday, today): + """RB-E05: End date must be after start date.""" + with pytest.raises(InvalidDateRangeError): + Education.create( + profile_id=profile_id, + institution="Universidad Complutense", + degree="Bachelor of Science", + field="Computer Science", + start_date=today, + end_date=yesterday, + order_index=0 + ) + + def test_rb_e05_end_date_cannot_equal_start_date(self, profile_id, today): + """RB-E05: End date cannot equal start date.""" + with pytest.raises(InvalidDateRangeError): + Education.create( + profile_id=profile_id, + institution="Universidad Complutense", + degree="Bachelor of Science", + field="Computer Science", + start_date=today, + end_date=today, + order_index=0 + ) + + def test_rb_e05_valid_date_range_accepted(self, profile_id, yesterday, tomorrow): + """RB-E05: Valid date range is accepted.""" + education = Education.create( + profile_id=profile_id, + institution="Universidad Complutense", + degree="Bachelor of Science", + field="Computer Science", + start_date=yesterday, + end_date=tomorrow, + order_index=0 + ) + + assert education.start_date == yesterday + assert education.end_date == tomorrow + + +# ========================================== +# CONTACT MESSAGE BUSINESS RULES TESTS +# ========================================== + +class TestContactMessageBusinessRules: + """Tests for ContactMessage entity business rules.""" + + def test_rb_cm01_name_is_required(self, valid_email): + """RB-CM01: Contact message name is required.""" + with pytest.raises((EmptyFieldError, InvalidNameError)): + ContactMessage.create( + name="", + email=valid_email, + message="This is a test message with enough characters" + ) + + def test_rb_cm01_name_not_whitespace_only(self, valid_email): + """RB-CM01: Contact message name cannot be whitespace only.""" + with pytest.raises((EmptyFieldError, InvalidNameError)): + ContactMessage.create( + name=" ", + email=valid_email, + message="This is a test message with enough characters" + ) + + def test_rb_cm02_email_is_required(self): + """RB-CM02: Contact message email is required.""" + with pytest.raises(EmptyFieldError): + ContactMessage.create( + name="John Doe", + email="", + message="This is a test message with enough characters" + ) + + def test_rb_cm02_email_must_be_valid_format(self): + """RB-CM02: Contact message email must be valid format.""" + with pytest.raises(InvalidEmailError): + ContactMessage.create( + name="John Doe", + email="invalid-email", + message="This is a test message with enough characters" + ) + + def test_rb_cm02_valid_email_accepted(self): + """RB-CM02: Valid email is accepted.""" + message = ContactMessage.create( + name="John Doe", + email="test@example.com", + message="This is a test message with enough characters" + ) + + assert message.email == "test@example.com" + + def test_rb_cm03_message_is_required(self, valid_email): + """RB-CM03: Contact message content is required.""" + with pytest.raises(EmptyFieldError): + ContactMessage.create( + name="John Doe", + email=valid_email, + message="" + ) + + def test_rb_cm03_message_min_length_enforced(self, valid_email): + """RB-CM03: Contact message has minimum length of 10 characters.""" + with pytest.raises(InvalidLengthError): + ContactMessage.create( + name="John Doe", + email=valid_email, + message="Short" + ) + + def test_rb_cm03_message_max_length_enforced(self, valid_email): + """RB-CM03: Contact message has maximum length of 2000 characters.""" + long_message = "x" * 2001 + + with pytest.raises(InvalidLengthError): + ContactMessage.create( + name="John Doe", + email=valid_email, + message=long_message + ) + + def test_rb_cm03_valid_message_length_accepted(self, valid_email): + """RB-CM03: Message within valid length range is accepted.""" + message = ContactMessage.create( + name="John Doe", + email=valid_email, + message="This is a valid message with enough characters to pass validation" + ) + + assert len(message.message) >= 10 + assert len(message.message) <= 2000 + + +# ========================================== +# VALUE OBJECT BUSINESS RULES TESTS +# ========================================== + +class TestValueObjectBusinessRules: + """Tests for Value Object business rules.""" + + def test_vr_e01_email_format_validated(self): + """VR-E01: Email value object validates format.""" + with pytest.raises(InvalidEmailError): + Email.create("invalid-email") + + def test_vr_e01_valid_email_accepted(self): + """VR-E01: Valid email format is accepted.""" + email = Email.create("test@example.com") + + assert email.value == "test@example.com" + + def test_vr_e01_email_variations_accepted(self): + """VR-E01: Various valid email formats are accepted.""" + valid_emails = [ + "user@example.com", + "user.name@example.com", + "user+tag@example.co.uk", + "user123@subdomain.example.com", + ] + + for email_str in valid_emails: + email = Email.create(email_str) + assert email.value == email_str + + def test_vr_p01_phone_format_validated(self): + """VR-P01: Phone value object validates E.164 format.""" + with pytest.raises(InvalidPhoneError): + Phone.create("abc") + + def test_vr_p01_phone_without_plus_rejected(self): + """VR-P01: Phone without + prefix is rejected.""" + with pytest.raises(InvalidPhoneError): + Phone.create("34612345678") + + def test_vr_p01_valid_phone_accepted(self): + """VR-P01: Valid E.164 phone format is accepted.""" + phone = Phone.create("+34612345678") + + assert phone.value == "+34612345678" + + def test_vr_sl01_skill_level_validated(self): + """VR-SL01: SkillLevel validates against allowed values.""" + with pytest.raises(InvalidSkillLevelError): + SkillLevel.create("invalid") + + def test_vr_sl01_valid_skill_levels_accepted(self): + """VR-SL01: All valid skill levels are accepted.""" + valid_levels = ["basic", "intermediate", "advanced", "expert"] + + for level_str in valid_levels: + level = SkillLevel.create(level_str) + assert level.to_string() == level_str + + def test_vr_dr01_start_date_required(self): + """VR-DR01: DateRange requires start date.""" + # DateRange.ongoing always needs a start date + start = datetime.now() + date_range = DateRange.ongoing(start) + + assert date_range.start_date == start + + def test_vr_dr02_end_date_must_be_after_start(self): + """VR-DR02: DateRange end date must be after start date.""" + start = datetime(2023, 1, 1) + end = datetime(2022, 1, 1) + + with pytest.raises(InvalidDateRangeError): + DateRange.completed(start_date=start, end_date=end) + + def test_vr_dr02_end_date_cannot_equal_start(self): + """VR-DR02: DateRange end date cannot equal start date.""" + same_date = datetime(2023, 1, 1) + + with pytest.raises(InvalidDateRangeError): + DateRange.completed(start_date=same_date, end_date=same_date) + + def test_vr_dr02_valid_date_range_accepted(self): + """VR-DR02: Valid date range is accepted.""" + start = datetime(2022, 1, 1) + end = datetime(2023, 1, 1) + + date_range = DateRange.completed(start_date=start, end_date=end) + + assert date_range.start_date == start + assert date_range.end_date == end + + +# ========================================== +# IMMUTABILITY CONSTRAINTS TESTS +# ========================================== + +class TestImmutabilityConstraints: + """Tests for immutability constraints on value objects.""" + + def test_email_is_immutable(self): + """Email value object should be immutable.""" + email = Email.create("test@example.com") + + with pytest.raises(AttributeError): + email.value = "new@example.com" + + def test_phone_is_immutable(self): + """Phone value object should be immutable.""" + phone = Phone.create("+34612345678") + + with pytest.raises(AttributeError): + phone.value = "+15551234567" + + def test_skill_level_is_immutable(self): + """SkillLevel value object should be immutable.""" + level = SkillLevel.create("basic") + + with pytest.raises(AttributeError): + level.level = SkillLevel.create("expert").level + + def test_date_range_is_immutable(self): + """DateRange value object should be immutable.""" + date_range = DateRange.ongoing(datetime.now()) + + with pytest.raises(AttributeError): + date_range.start_date = datetime.now() + + def test_date_range_end_date_immutable(self): + """DateRange end_date should be immutable.""" + start = datetime(2022, 1, 1) + end = datetime(2023, 1, 1) + date_range = DateRange.completed(start_date=start, end_date=end) + + with pytest.raises(AttributeError): + date_range.end_date = datetime(2024, 1, 1) + + +# ========================================== +# CROSS-CUTTING CONCERNS TESTS +# ========================================== + +class TestCrossCuttingBusinessRules: + """Tests for cross-cutting business rules.""" + + def test_order_index_cannot_be_negative_skill(self, profile_id): + """Order index must be non-negative for Skill.""" + with pytest.raises(Exception): # Could be InvalidOrderIndexError + Skill.create( + profile_id=profile_id, + name="Python", + category="backend", + order_index=-1 + ) + + def test_order_index_cannot_be_negative_education(self, profile_id, today): + """Order index must be non-negative for Education.""" + with pytest.raises(Exception): # Could be InvalidOrderIndexError + Education.create( + profile_id=profile_id, + institution="Universidad Complutense", + degree="Bachelor of Science", + field="Computer Science", + start_date=today, + order_index=-1 + ) + + def test_order_index_cannot_be_negative_work_experience(self, profile_id, today): + """Order index must be non-negative for WorkExperience.""" + with pytest.raises(Exception): # Could be InvalidOrderIndexError + WorkExperience.create( + profile_id=profile_id, + role="Software Engineer", + company="Test Company", + start_date=today, + order_index=-1 + ) + + def test_order_index_zero_is_valid(self, profile_id): + """Order index of 0 should be valid (first position).""" + skill = Skill.create( + profile_id=profile_id, + name="Python", + category="backend", + order_index=0 + ) + + assert skill.order_index == 0 + + +# ========================================== +# INTEGRATION TESTS +# ========================================== + +class TestBusinessRulesIntegration: + """Integration tests for business rules working together.""" + + def test_profile_with_all_valid_fields(self): + """Should create profile with all valid fields.""" + profile = Profile.create( + name="John Doe", + headline="Senior Software Engineer", + bio="Experienced developer", + location="Madrid, Spain", + avatar_url="https://example.com/avatar.jpg" + ) + + assert profile.name == "John Doe" + assert profile.headline == "Senior Software Engineer" + assert profile.bio == "Experienced developer" + assert profile.location == "Madrid, Spain" + assert profile.avatar_url == "https://example.com/avatar.jpg" + + def test_complete_work_experience_lifecycle(self, profile_id): + """Should create and update work experience following all rules.""" + yesterday = datetime.now() - timedelta(days=1) + tomorrow = datetime.now() + timedelta(days=1) + + # Create + experience = WorkExperience.create( + profile_id=profile_id, + role="Software Engineer", + company="Tech Corp", + start_date=yesterday, + end_date=tomorrow, + order_index=0 + ) + + assert experience.role == "Software Engineer" + assert experience.company == "Tech Corp" + + # Update + experience.update_info(role="Senior Software Engineer") + assert experience.role == "Senior Software Engineer" + + def test_complete_education_lifecycle(self, profile_id): + """Should create and update education following all rules.""" + yesterday = datetime.now() - timedelta(days=1) + tomorrow = datetime.now() + timedelta(days=1) + + # Create + education = Education.create( + profile_id=profile_id, + institution="Universidad Complutense", + degree="Bachelor of Science", + field="Computer Science", + start_date=yesterday, + end_date=tomorrow, + order_index=0 + ) + + assert education.institution == "Universidad Complutense" + assert education.degree == "Bachelor of Science" + + # Update + education.update_info(degree="Master of Science") + assert education.degree == "Master of Science" + + def test_contact_message_complete_flow(self): + """Should create and manage contact message following all rules.""" + message = ContactMessage.create( + name="John Doe", + email="john@example.com", + message="This is a valid message with enough characters to pass all validation" + ) + + assert message.status == "pending" + assert message.is_pending() + + message.mark_as_read() + assert message.status == "read" + assert message.is_read() + + message.mark_as_replied() + assert message.status == "replied" + assert message.is_replied() From 489a18d3aa7b3f1ed11602420cff6adc381aec49 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 5 Feb 2026 02:26:09 +0100 Subject: [PATCH 2/5] feat: (#60) Configure and run tests in CI + Fix errors resulting from the tests --- .github/workflows/README.md | 180 ++++++++++++++++ .github/workflows/lint.yml | 85 ++++++++ .github/workflows/tests.yml | 138 ++++++++++++ .gitignore | 6 +- README.md | 4 + app/api/middleware.py | 10 +- app/api/schemas/__init__.py | 90 ++++---- app/api/schemas/additional_training_schema.py | 66 ++++-- app/api/schemas/certification_schema.py | 59 +++-- app/api/schemas/common_schema.py | 19 +- app/api/schemas/contact_info_schema.py | 43 ++-- app/api/schemas/contact_messages_schema.py | 35 +-- app/api/schemas/cv_schema.py | 42 ++-- app/api/schemas/education_schema.py | 81 ++++--- app/api/schemas/profile_schema.py | 53 +++-- app/api/schemas/projects_schema.py | 63 ++++-- app/api/schemas/skill_schema.py | 49 +++-- app/api/schemas/social_networks_schema.py | 53 +++-- app/api/schemas/tools_schema.py | 49 +++-- app/api/schemas/work_experience_schema.py | 95 +++++--- app/api/v1/router.py | 19 +- .../v1/routers/additional_training_router.py | 132 ++++++------ app/api/v1/routers/certification_router.py | 175 +++++++-------- app/api/v1/routers/contact_info_router.py | 72 +++---- app/api/v1/routers/contact_messages_router.py | 150 +++++++------ app/api/v1/routers/cv_router.py | 108 +++++----- app/api/v1/routers/education_router.py | 111 +++++----- app/api/v1/routers/health_router.py | 16 +- app/api/v1/routers/profile_router.py | 64 +++--- app/api/v1/routers/projects_router.py | 151 +++++++------ app/api/v1/routers/skill_router.py | 200 +++++++++-------- app/api/v1/routers/social_networks_router.py | 22 +- app/api/v1/routers/tools_router.py | 34 +-- app/api/v1/routers/work_experience_router.py | 175 +++++++-------- app/application/__init__.py | 2 +- app/application/dto/__init__.py | 60 +++--- app/application/dto/base_dto.py | 19 +- app/application/dto/cv_dto.py | 18 +- app/application/dto/education_dto.py | 31 +-- app/application/dto/profile_dto.py | 29 +-- app/application/dto/skill_dto.py | 101 ++++----- app/application/dto/work_experience_dto.py | 41 ++-- app/application/use_cases/__init__.py | 35 +-- app/application/use_cases/cv/__init__.py | 4 +- .../use_cases/cv/generate_cv_pdf.py | 31 +-- .../use_cases/cv/get_complete_cv.py | 57 +++-- .../use_cases/education/__init__.py | 4 +- .../use_cases/education/add_education.py | 34 +-- .../use_cases/education/delete_education.py | 25 +-- .../use_cases/education/edit_education.py | 29 +-- app/application/use_cases/profile/__init__.py | 4 +- .../use_cases/profile/create_profile.py | 26 +-- .../use_cases/profile/get_profile.py | 24 +-- .../use_cases/profile/update_profile.py | 26 +-- app/application/use_cases/skill/__init__.py | 4 +- app/application/use_cases/skill/add_skill.py | 29 +-- .../use_cases/skill/delete_skill.py | 25 +-- app/application/use_cases/skill/edit_skill.py | 40 ++-- .../use_cases/skill/list_skills.py | 26 +-- .../use_cases/work_experience/__init__.py | 4 +- .../work_experience/add_experience.py | 38 ++-- .../work_experience/delete_experience.py | 29 +-- .../work_experience/edit_experience.py | 35 +-- .../work_experience/list_experiences.py | 32 +-- app/config/settings.py | 36 ++-- app/domain/__init__.py | 62 +++--- app/domain/entities/additional_training.py | 84 ++++---- app/domain/entities/certification.py | 84 ++++---- app/domain/entities/contact_information.py | 77 +++---- app/domain/entities/contact_message.py | 43 ++-- app/domain/entities/education.py | 28 +-- app/domain/entities/profile.py | 29 ++- app/domain/entities/project.py | 123 ++++++----- app/domain/entities/skill.py | 11 +- app/domain/entities/social_network.py | 58 ++--- app/domain/entities/tool.py | 11 +- app/domain/entities/work_experience.py | 92 ++++---- app/domain/exceptions/__init__.py | 24 +-- app/domain/exceptions/domain_errors.py | 40 +++- app/domain/value_objects/contact_info.py | 47 ++-- app/domain/value_objects/date_range.py | 74 +++---- app/domain/value_objects/email.py | 40 ++-- app/domain/value_objects/phone.py | 7 +- app/domain/value_objects/skill_level.py | 55 ++--- app/infrastructure/database/database.py | 23 +- app/infrastructure/database/mongo_client.py | 28 +-- app/main.py | 35 ++- app/shared/__init__.py | 23 +- app/shared/interfaces/__init__.py | 44 ++-- app/shared/interfaces/mapper.py | 98 ++++----- app/shared/interfaces/repository.py | 203 +++++++++--------- app/shared/interfaces/use_case.py | 68 +++--- app/shared/shared_exceptions/__init__.py | 7 +- pyproject.toml | 145 +++++++++++++ requirements-dev.txt | 8 +- scripts/validate_business_rules.py | 33 +-- scripts/validate_imports.py | 48 +---- tests/__init__.py | 2 +- tests/conftest.py | 48 +++-- tests/integration/__init__.py | 2 +- tests/integration/test_health.py | 11 +- tests/test_connection.py | 9 +- tests/unit/__init__.py | 2 +- .../domain/entities/test_contact_message.py | 27 ++- tests/unit/domain/entities/test_education.py | 23 +- tests/unit/domain/entities/test_profile.py | 22 +- tests/unit/domain/entities/test_skill.py | 48 ++--- tests/unit/domain/test_business_rules.py | 171 +++++++-------- .../domain/value_objects/test_date_range.py | 20 +- tests/unit/domain/value_objects/test_email.py | 17 +- tests/unit/domain/value_objects/test_phone.py | 10 +- .../domain/value_objects/test_skill_level.py | 5 +- tests/unit/test_config.py | 19 +- tests/unit/test_imports.py | 17 +- tests/unit/test_schemas.py | 57 +++-- 115 files changed, 3270 insertions(+), 2539 deletions(-) create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/tests.yml create mode 100644 pyproject.toml diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..2165635 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,180 @@ +# GitHub Actions Workflows + +Este directorio contiene los workflows de CI/CD para el proyecto Portfolio Backend. + +## 📋 Workflows Disponibles + +### 1. **lint.yml** - Code Quality & Linting + +Ejecuta verificaciones de calidad de código en cada push y pull request. + +**Verifica:** +- ✅ **Black**: Formateo de código +- ✅ **Isort**: Ordenamiento de imports +- ✅ **Ruff**: Linting general +- ✅ **MyPy**: Type checking +- ✅ **Bandit**: Análisis de seguridad +- ✅ **Safety**: Vulnerabilidades en dependencias + +**Se ejecuta en:** +- Python 3.12 y 3.13 +- Branches: `main`, `develop`, `feature/**` + +### 2. **tests.yml** - Tests + +Ejecuta todos los tests del proyecto con cobertura. + +**Incluye:** +- 🧪 Tests unitarios +- 🔗 Tests de integración +- 🌐 Tests end-to-end +- 📊 Reporte de cobertura (Codecov) +- 🗄️ MongoDB 8.0 service + +**Se ejecuta en:** +- Python 3.12 y 3.13 +- Branches: `main`, `develop`, `feature/**` + +## 🔐 Configuración de Secretos + +Para que los workflows funcionen correctamente, necesitas configurar los siguientes secretos en GitHub: + +### Pasos para configurar secretos: + +1. Ve a tu repositorio en GitHub +2. Click en `Settings` > `Secrets and variables` > `Actions` +3. Click en `New repository secret` +4. Agrega los siguientes secretos: + +### Secretos Requeridos: + +#### **CODECOV_TOKEN** (Opcional pero recomendado) +Para subir reportes de cobertura a Codecov. + +``` +1. Ve a https://codecov.io/ +2. Conecta tu repositorio de GitHub +3. Copia el token de Codecov +4. Agrégalo como secreto en GitHub con el nombre: CODECOV_TOKEN +``` + +#### **MONGODB_URL** (Opcional) +URL de conexión a MongoDB para tests. Si no se configura, usa `mongodb://localhost:27017` por defecto. + +``` +Nombre: MONGODB_URL +Valor: mongodb://localhost:27017 +``` + +#### **SECRET_KEY** (Futuro) +Clave secreta para la aplicación. Si no se configura, usa un valor por defecto para testing. + +``` +Nombre: SECRET_KEY +Valor: tu-clave-secreta-aqui +``` + +## 📊 Badges para README + +Puedes agregar estos badges a tu README.md principal: + +```markdown +![Code Quality](https://github.com/USUARIO/REPO/workflows/Code%20Quality%20&%20Linting/badge.svg) +![Tests](https://github.com/USUARIO/REPO/workflows/Tests/badge.svg) +[![codecov](https://codecov.io/gh/USUARIO/REPO/branch/main/graph/badge.svg)](https://codecov.io/gh/USUARIO/REPO) +``` + +Reemplaza `USUARIO` y `REPO` con tu información. + +## 🚀 Ejecución Manual + +Puedes ejecutar los workflows manualmente desde la pestaña "Actions" en GitHub: + +1. Ve a la pestaña `Actions` +2. Selecciona el workflow que quieres ejecutar +3. Click en `Run workflow` +4. Selecciona la branch +5. Click en `Run workflow` + +## 🔧 Configuración Local + +Para ejecutar las mismas validaciones localmente: + +### Code Quality: + +```bash +# Formateo +black app/ tests/ +isort app/ tests/ + +# Linting +ruff check app/ tests/ + +# Type checking +mypy app/ + +# Security +bandit -r app/ +safety check +``` + +### Tests: + +```bash +# Todos los tests +pytest tests/ -v + +# Solo unitarios +pytest tests/unit/ -v + +# Con cobertura +pytest tests/ --cov=app --cov-report=html + +# En paralelo +pytest tests/ -n auto +``` + +## 📁 Estructura de Artifacts + +Los workflows generan artifacts que puedes descargar: + +### Lint Workflow: +- `bandit-report-py3.12.json` +- `bandit-report-py3.13.json` + +### Tests Workflow: +- `test-results-unit-py3.12/` +- `test-results-integration-py3.12/` +- `test-results-e2e-py3.12/` +- (Similar para Python 3.13) + +Cada artifact incluye: +- Archivo JUnit XML con resultados +- Reporte HTML de cobertura + +## 🐛 Troubleshooting + +### Error: "MongoDB not ready" + +Si los tests fallan porque MongoDB no está listo: +- El workflow ya incluye un paso `Wait for MongoDB` +- Aumenta el tiempo de espera si es necesario + +### Error: "Module not found" + +Si falta alguna dependencia: +1. Verifica que esté en `requirements-dev.txt` +2. Haz commit y push de los cambios +3. El workflow instalará la nueva dependencia + +### Error: "Coverage upload failed" + +Si Codecov falla: +- Verifica que el token `CODECOV_TOKEN` esté configurado +- El workflow está configurado con `fail_ci_if_error: false`, así que no bloqueará el CI + +## 📖 Documentación Adicional + +- [GitHub Actions Documentation](https://docs.github.com/en/actions) +- [Codecov Documentation](https://docs.codecov.com/) +- [pytest Documentation](https://docs.pytest.org/) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..b90606e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,85 @@ +name: Code Quality & Linting + +on: + push: + branches: + - main + - develop + - 'feature/**' + pull_request: + branches: + - main + - develop + +jobs: + lint: + name: Code Quality Checks + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ['3.12', '3.13'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Check code formatting with Black + run: | + black --check app/ tests/ + continue-on-error: false + + - name: Check import sorting with isort + run: | + isort --check-only app/ tests/ + continue-on-error: false + + - name: Lint with Ruff + run: | + ruff check app/ tests/ + continue-on-error: false + + - name: Type checking with MyPy + run: | + mypy app/ --install-types --non-interactive + continue-on-error: false + + - name: Security check with Bandit + run: | + bandit -r app/ -f json -o bandit-report.json + continue-on-error: true + + - name: Check dependencies for vulnerabilities + run: | + safety check --json + continue-on-error: true + + - name: Upload Bandit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: bandit-report-py${{ matrix.python-version }} + path: bandit-report.json + retention-days: 30 + + - name: Summary + if: always() + run: | + echo "### Code Quality Check Results 🔍" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Black formatting check" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Isort import sorting check" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Ruff linting check" >> $GITHUB_STEP_SUMMARY + echo "- ✅ MyPy type checking" >> $GITHUB_STEP_SUMMARY + echo "- ℹ️ Security checks (see artifacts)" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..762259d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,138 @@ +name: Tests + +on: + push: + branches: + - main + - develop + - 'feature/**' + pull_request: + branches: + - main + - develop + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ['3.12', '3.13'] + test-suite: ['unit', 'integration', 'e2e'] + + services: + mongodb: + image: mongo:8.0 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --eval 'db.adminCommand({ping: 1})'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Wait for MongoDB + run: | + until mongosh --eval "db.adminCommand('ping')" > /dev/null 2>&1; do + echo "Waiting for MongoDB..." + sleep 2 + done + echo "MongoDB is ready!" + + - name: Run ${{ matrix.test-suite }} tests + env: + MONGODB_URL: ${{ secrets.MONGODB_URL || 'mongodb://localhost:27017' }} + MONGODB_DB_NAME: portfolio_test_db + ENVIRONMENT: test + SECRET_KEY: ${{ secrets.SECRET_KEY || 'test-secret-key-for-ci' }} + run: | + pytest tests/${{ matrix.test-suite }}/ \ + -v \ + --tb=short \ + --cov=app \ + --cov-report=xml \ + --cov-report=term-missing \ + --cov-report=html \ + --junit-xml=junit-${{ matrix.test-suite }}-py${{ matrix.python-version }}.xml + + - name: Upload coverage to Codecov + if: matrix.test-suite == 'unit' && matrix.python-version == '3.13' + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + flags: ${{ matrix.test-suite }} + name: codecov-py${{ matrix.python-version }} + fail_ci_if_error: false + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.test-suite }}-py${{ matrix.python-version }} + path: | + junit-*.xml + htmlcov/ + retention-days: 30 + + - name: Test Summary + if: always() + run: | + echo "### Test Results 🧪" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Test Suite:** ${{ matrix.test-suite }}" >> $GITHUB_STEP_SUMMARY + echo "**Python:** ${{ matrix.python-version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -f junit-${{ matrix.test-suite }}-py${{ matrix.python-version }}.xml ]; then + echo "✅ Tests completed" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Tests failed" >> $GITHUB_STEP_SUMMARY + fi + + test-all-passed: + name: All Tests Passed + runs-on: ubuntu-latest + needs: test + if: always() + + steps: + - name: Check test results + run: | + if [[ "${{ needs.test.result }}" == "success" ]]; then + echo "✅ All tests passed!" + exit 0 + else + echo "❌ Some tests failed" + exit 1 + fi + + - name: Final Summary + if: always() + run: | + echo "### 🎯 Final Test Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ${{ needs.test.result }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [[ "${{ needs.test.result }}" == "success" ]]; then + echo "✅ All test suites passed across all Python versions" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Some test suites failed. Check the logs above." >> $GITHUB_STEP_SUMMARY + fi diff --git a/.gitignore b/.gitignore index f4e449b..db31ff0 100644 --- a/.gitignore +++ b/.gitignore @@ -242,4 +242,8 @@ $RECYCLE.BIN/ # secrets config.env -.vscode \ No newline at end of file +.vscode + +.claude + +.benchmarks \ No newline at end of file diff --git a/README.md b/README.md index e7ddd15..9a6a2f1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ Este repositorio forma parte de un ecosistema de 3 aplicaciones: ![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=for-the-badge&logo=fastapi&logoColor=white) ![MongoDB](https://img.shields.io/badge/MongoDB-47A248?style=for-the-badge&logo=mongodb&logoColor=white) +![Code Quality](https://github.com/Azfe/portfolio_backend/workflows/Code%20Quality%20&%20Linting/badge.svg) +![Tests](https://github.com/Azfe/portfolio_backend/workflows/Tests/badge.svg) +[![codecov](https://codecov.io/gh/Azfe/portfolio_backend/branch/main/graph/badge.svg)](https://codecov.io/gh/Azfe/portfolio_backend) + ## 🚀 Inicio Rápido con Docker (Recomendado) ### Requisitos Previos diff --git a/app/api/middleware.py b/app/api/middleware.py index b62b234..4f28f43 100644 --- a/app/api/middleware.py +++ b/app/api/middleware.py @@ -1,14 +1,16 @@ +import logging + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware + from app.config.settings import settings -import logging logger = logging.getLogger(__name__) def setup_middleware(app: FastAPI): """Configurar middlewares de la aplicación""" - + # CORS Middleware app.add_middleware( CORSMiddleware, @@ -17,5 +19,5 @@ def setup_middleware(app: FastAPI): allow_methods=settings.cors_methods_list, allow_headers=settings.cors_headers_list, ) - - logger.info(f"✓ CORS configurado: {settings.cors_origins_list}") \ No newline at end of file + + logger.info(f"✓ CORS configurado: {settings.cors_origins_list}") diff --git a/app/api/schemas/__init__.py b/app/api/schemas/__init__.py index 84865d7..b764cf3 100644 --- a/app/api/schemas/__init__.py +++ b/app/api/schemas/__init__.py @@ -1,72 +1,58 @@ """Schemas de la API - Contratos de entrada/salida""" +from .additional_training_schema import ( + AdditionalTrainingCreate, + AdditionalTrainingResponse, + AdditionalTrainingUpdate, +) +from .certification_schema import ( + CertificationCreate, + CertificationResponse, + CertificationUpdate, +) from .common_schema import ( - SuccessResponse, ErrorResponse, MessageResponse, - TimestampMixin -) -from .profile_schema import ( - ProfileResponse, - ProfileCreate, - ProfileUpdate + SuccessResponse, + TimestampMixin, ) from .contact_info_schema import ( - ContactInformationResponse, ContactInformationCreate, - ContactInformationUpdate -) -from .social_networks_schema import ( - SocialNetworkResponse, - SocialNetworkCreate, - SocialNetworkUpdate -) -from .projects_schema import ( - ProjectResponse, - ProjectCreate, - ProjectUpdate + ContactInformationResponse, + ContactInformationUpdate, ) -from .work_experience_schema import ( - WorkExperienceResponse, - WorkExperienceCreate, - WorkExperienceUpdate +from .contact_messages_schema import ( # MessageStatus + ContactMessageCreate, + ContactMessageResponse, + ContactMessageUpdate, ) +from .cv_schema import CVCompleteResponse +from .education_schema import EducationCreate, EducationResponse, EducationUpdate +from .profile_schema import ProfileCreate, ProfileResponse, ProfileUpdate +from .projects_schema import ProjectCreate, ProjectResponse, ProjectUpdate from .skill_schema import ( - SkillResponse, + SkillCategory, SkillCreate, - SkillUpdate, SkillLevel, - SkillCategory + SkillResponse, + SkillUpdate, ) -from .tools_schema import ( - ToolResponse, +from .social_networks_schema import ( + SocialNetworkCreate, + SocialNetworkResponse, + SocialNetworkUpdate, +) +from .tools_schema import ( # ToolLevel + ToolCategory, ToolCreate, + ToolResponse, ToolUpdate, - ToolCategory, - #ToolLevel -) -from .education_schema import ( - EducationResponse, - EducationCreate, - EducationUpdate -) -from .additional_training_schema import ( - AdditionalTrainingResponse, - AdditionalTrainingCreate, - AdditionalTrainingUpdate ) -from .certification_schema import ( - CertificationResponse, - CertificationCreate, - CertificationUpdate -) -from .contact_messages_schema import ( - ContactMessageResponse, - ContactMessageCreate, - ContactMessageUpdate, - #MessageStatus +from .work_experience_schema import ( + WorkExperienceCreate, + WorkExperienceResponse, + WorkExperienceUpdate, ) -from .cv_schema import CVCompleteResponse __all__ = [ # Common @@ -126,4 +112,4 @@ "MessageStatus", # CV Complete "CVCompleteResponse", -] \ No newline at end of file +] diff --git a/app/api/schemas/additional_training_schema.py b/app/api/schemas/additional_training_schema.py index eb67efb..bf3c90e 100644 --- a/app/api/schemas/additional_training_schema.py +++ b/app/api/schemas/additional_training_schema.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, Field -from typing import Optional, List from datetime import date + +from pydantic import BaseModel, Field + from app.api.schemas.common_schema import TimestampMixin @@ -9,55 +10,76 @@ class AdditionalTrainingBase(BaseModel): Formación complementaria no académica del usuario. Representa cursos, talleres, workshops, bootcamps, etc. """ - title: str = Field(..., min_length=1, description="Nombre del curso o formación (no puede estar vacío)") - institution: str = Field(..., min_length=1, description="Entidad que lo impartió (no puede estar vacía)") + + title: str = Field( + ..., + min_length=1, + description="Nombre del curso o formación (no puede estar vacío)", + ) + institution: str = Field( + ..., min_length=1, description="Entidad que lo impartió (no puede estar vacía)" + ) end_date: date = Field(..., description="Fecha de realización (obligatoria)") - duration_hours: Optional[int] = Field(None, ge=1, description="Duración en horas (opcional)") - description: Optional[str] = Field(None, description="Detalles adicionales (temario, logros, etc.)") - location: Optional[str] = Field(None, description="Ubicación del centro (presencial, online, ciudad)") - technologies: List[str] = Field(default_factory=list, description="Tecnologías aprendidas (se vincula con Skills)") - order_index: int = Field(..., ge=0, description="Orden de aparición en el portafolio") + duration_hours: int | None = Field( + None, ge=1, description="Duración en horas (opcional)" + ) + description: str | None = Field( + None, description="Detalles adicionales (temario, logros, etc.)" + ) + location: str | None = Field( + None, description="Ubicación del centro (presencial, online, ciudad)" + ) + technologies: list[str] = Field( + default_factory=list, + description="Tecnologías aprendidas (se vincula con Skills)", + ) + order_index: int = Field( + ..., ge=0, description="Orden de aparición en el portafolio" + ) class AdditionalTrainingCreate(AdditionalTrainingBase): """ Schema para crear formación adicional. - + Invariantes: - title no puede estar vacío - institution no puede estar vacía - date es obligatoria """ + pass class AdditionalTrainingUpdate(BaseModel): """ Schema para actualizar formación adicional. - + Todos los campos son opcionales, pero title e institution no pueden quedar vacíos si se actualizan. """ - title: Optional[str] = Field(None, min_length=1) - institution: Optional[str] = Field(None, min_length=1) - end_date: Optional[date] = None - duration_hours: Optional[int] = Field(None, ge=1) - description: Optional[str] = None - location: Optional[str] = None - technologies: Optional[List[str]] = None - order_index: Optional[int] = Field(None, ge=0) + + title: str | None = Field(None, min_length=1) + institution: str | None = Field(None, min_length=1) + end_date: date | None = None + duration_hours: int | None = Field(None, ge=1) + description: str | None = None + location: str | None = None + technologies: list[str] | None = None + order_index: int | None = Field(None, ge=0) class AdditionalTrainingResponse(AdditionalTrainingBase, TimestampMixin): """ Schema de respuesta de formación adicional. - + Relaciones: - Pertenece a un único Profile - Un Profile tiene muchos AdditionalTraining - technologies se vincula con Skills (tecnologías aprendidas) """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/certification_schema.py b/app/api/schemas/certification_schema.py index c5733e5..e6aedab 100644 --- a/app/api/schemas/certification_schema.py +++ b/app/api/schemas/certification_schema.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, Field, HttpUrl -from typing import Optional from datetime import date + +from pydantic import BaseModel, Field, HttpUrl + from app.api.schemas.common_schema import TimestampMixin @@ -9,52 +10,70 @@ class CertificationBase(BaseModel): Certificación profesional obtenida por el usuario. Representa certificaciones oficiales de proveedores tecnológicos, organizaciones, etc. """ - name: str = Field(..., min_length=1, description="Nombre de la certificación (no puede estar vacío)") - issuer: str = Field(..., min_length=1, description="Entidad emisora (no puede estar vacía)") + + name: str = Field( + ..., + min_length=1, + description="Nombre de la certificación (no puede estar vacío)", + ) + issuer: str = Field( + ..., min_length=1, description="Entidad emisora (no puede estar vacía)" + ) issue_date: date = Field(..., description="Fecha de emisión (obligatoria)") - expiration_date: Optional[date] = Field(None, description="Fecha de expiración (opcional, None = no expira)") - credential_id: Optional[str] = Field(None, description="Identificador de la credencial (opcional)") - credential_url: Optional[HttpUrl] = Field(None, description="URL verificable de la credencial (opcional)") - order_index: int = Field(..., ge=0, description="Orden de aparición en el portafolio") + expiration_date: date | None = Field( + None, description="Fecha de expiración (opcional, None = no expira)" + ) + credential_id: str | None = Field( + None, description="Identificador de la credencial (opcional)" + ) + credential_url: HttpUrl | None = Field( + None, description="URL verificable de la credencial (opcional)" + ) + order_index: int = Field( + ..., ge=0, description="Orden de aparición en el portafolio" + ) class CertificationCreate(CertificationBase): """ Schema para crear certificación. - + Invariantes: - name no puede estar vacío - issuer no puede estar vacío - issueDate es obligatoria """ + pass class CertificationUpdate(BaseModel): """ Schema para actualizar certificación. - + Todos los campos son opcionales, pero name e issuer no pueden quedar vacíos si se actualizan. """ - name: Optional[str] = Field(None, min_length=1) - issuer: Optional[str] = Field(None, min_length=1) - issue_date: Optional[date] = None - expiration_date: Optional[date] = None - credential_id: Optional[str] = None - credential_url: Optional[HttpUrl] = None - order_index: Optional[int] = Field(None, ge=0) + + name: str | None = Field(None, min_length=1) + issuer: str | None = Field(None, min_length=1) + issue_date: date | None = None + expiration_date: date | None = None + credential_id: str | None = None + credential_url: HttpUrl | None = None + order_index: int | None = Field(None, ge=0) class CertificationResponse(CertificationBase, TimestampMixin): """ Schema de respuesta de certificación. - + Relaciones: - Pertenece a un único Profile - Un Profile tiene muchas Certification """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/common_schema.py b/app/api/schemas/common_schema.py index 5afcb91..c67cc07 100644 --- a/app/api/schemas/common_schema.py +++ b/app/api/schemas/common_schema.py @@ -1,33 +1,38 @@ -from pydantic import BaseModel -from typing import Generic, TypeVar, Optional from datetime import datetime +from typing import Generic, TypeVar + +from pydantic import BaseModel # Generic type para responses -T = TypeVar('T') +T = TypeVar("T") class SuccessResponse(BaseModel, Generic[T]): """Respuesta exitosa genérica""" + success: bool = True data: T - message: Optional[str] = None + message: str | None = None class ErrorResponse(BaseModel): """Respuesta de error""" + success: bool = False error: str message: str - code: Optional[str] = None + code: str | None = None class MessageResponse(BaseModel): """Respuesta simple con mensaje""" + success: bool = True message: str class TimestampMixin(BaseModel): """Mixin para campos de timestamp""" - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None \ No newline at end of file + + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/app/api/schemas/contact_info_schema.py b/app/api/schemas/contact_info_schema.py index 4066bbe..e18f7d5 100644 --- a/app/api/schemas/contact_info_schema.py +++ b/app/api/schemas/contact_info_schema.py @@ -1,5 +1,6 @@ -from pydantic import BaseModel, EmailStr, HttpUrl, Field -from typing import Optional + +from pydantic import BaseModel, EmailStr, Field, HttpUrl + from app.api.schemas.common_schema import TimestampMixin @@ -8,47 +9,57 @@ class ContactInformationBase(BaseModel): Información de contacto pública del usuario. Representa los datos de contacto visibles en el portfolio. """ - email: EmailStr = Field(..., description="Correo de contacto (no puede estar vacío)") - phone: Optional[str] = Field(None, description="Número de teléfono (opcional)") - location: Optional[str] = Field(None, description="Ubicación general (ciudad, país)") - website: Optional[HttpUrl] = Field(None, description="Sitio web personal (opcional)") + + email: EmailStr = Field( + ..., description="Correo de contacto (no puede estar vacío)" + ) + phone: str | None = Field(None, description="Número de teléfono (opcional)") + location: str | None = Field( + None, description="Ubicación general (ciudad, país)" + ) + website: HttpUrl | None = Field( + None, description="Sitio web personal (opcional)" + ) class ContactInformationCreate(ContactInformationBase): """ Schema para crear información de contacto. - + Invariantes: - email no puede estar vacío - Solo puede existir una ContactInformation por perfil """ + pass class ContactInformationUpdate(BaseModel): """ Schema para actualizar información de contacto. - + Todos los campos son opcionales, pero email no puede quedar vacío si se actualiza. """ - email: Optional[EmailStr] = None - phone: Optional[str] = None - location: Optional[str] = None - website: Optional[HttpUrl] = None + + email: EmailStr | None = None + phone: str | None = None + location: str | None = None + website: HttpUrl | None = None class ContactInformationResponse(ContactInformationBase, TimestampMixin): """ Schema de respuesta de información de contacto. - + Relaciones: - Pertenece a un único Profile (relación 1-a-1) - Un Profile tiene una única ContactInformation - + Invariantes: - Solo puede existir una ContactInformation por perfil """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/contact_messages_schema.py b/app/api/schemas/contact_messages_schema.py index 5bb5bb2..7a7c26e 100644 --- a/app/api/schemas/contact_messages_schema.py +++ b/app/api/schemas/contact_messages_schema.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, EmailStr, Field -from typing import Optional from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field + from app.api.schemas.common_schema import TimestampMixin @@ -9,23 +10,31 @@ class ContactMessageBase(BaseModel): Mensaje enviado desde el formulario de contacto del portfolio. Representa mensajes de visitantes o potenciales clientes/empleadores. """ - name: str = Field(..., min_length=1, description="Nombre del remitente (no puede estar vacío)") - email: EmailStr = Field(..., description="Correo del remitente (no puede estar vacío)") - message: str = Field(..., min_length=1, description="Contenido del mensaje (no puede estar vacío)") + + name: str = Field( + ..., min_length=1, description="Nombre del remitente (no puede estar vacío)" + ) + email: EmailStr = Field( + ..., description="Correo del remitente (no puede estar vacío)" + ) + message: str = Field( + ..., min_length=1, description="Contenido del mensaje (no puede estar vacío)" + ) sent_at: datetime = Field(..., description="Fecha y hora del envío (obligatorio)") class ContactMessageCreate(BaseModel): """ Schema para crear mensaje de contacto (desde formulario público). - + Nota: sent_at se genera automáticamente en el servidor. - + Invariantes: - name no puede estar vacío - email no puede estar vacío y debe ser válido - message no puede estar vacío """ + name: str = Field(..., min_length=1) email: EmailStr message: str = Field(..., min_length=1) @@ -34,10 +43,11 @@ class ContactMessageCreate(BaseModel): class ContactMessageUpdate(BaseModel): """ Schema para actualizar mensaje (solo admin). - + Normalmente solo se actualizaría para marcar como leído, respondido, etc. Los campos del remitente NO deberían modificarse. """ + # No se permite actualizar name, email, message, sent_at # Solo campos administrativos (implementar según necesidad) pass @@ -46,15 +56,16 @@ class ContactMessageUpdate(BaseModel): class ContactMessageResponse(ContactMessageBase, TimestampMixin): """ Schema de respuesta de mensaje de contacto. - + Relaciones: - Pertenece a un único Profile - Un Profile tiene muchos ContactMessage - + Invariantes: - sent_at es obligatorio (generado al crear) """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/cv_schema.py b/app/api/schemas/cv_schema.py index 1061053..6f0bd18 100644 --- a/app/api/schemas/cv_schema.py +++ b/app/api/schemas/cv_schema.py @@ -1,15 +1,16 @@ + from pydantic import BaseModel -from typing import List -from app.api.schemas.profile_schema import ProfileResponse + +from app.api.schemas.additional_training_schema import AdditionalTrainingResponse +from app.api.schemas.certification_schema import CertificationResponse from app.api.schemas.contact_info_schema import ContactInformationResponse -from app.api.schemas.social_networks_schema import SocialNetworkResponse +from app.api.schemas.education_schema import EducationResponse +from app.api.schemas.profile_schema import ProfileResponse from app.api.schemas.projects_schema import ProjectResponse -from app.api.schemas.work_experience_schema import WorkExperienceResponse from app.api.schemas.skill_schema import SkillResponse +from app.api.schemas.social_networks_schema import SocialNetworkResponse from app.api.schemas.tools_schema import ToolResponse -from app.api.schemas.education_schema import EducationResponse -from app.api.schemas.additional_training_schema import AdditionalTrainingResponse -from app.api.schemas.certification_schema import CertificationResponse +from app.api.schemas.work_experience_schema import WorkExperienceResponse class CVCompleteResponse(BaseModel): @@ -17,23 +18,24 @@ class CVCompleteResponse(BaseModel): Schema del CV completo. Agrupa TODA la información del portfolio. """ + # Información personal profile: ProfileResponse contact_info: ContactInformationResponse - social_networks: List[SocialNetworkResponse] = [] - + social_networks: list[SocialNetworkResponse] = [] + # Experiencia profesional - work_experiences: List[WorkExperienceResponse] = [] - projects: List[ProjectResponse] = [] - + work_experiences: list[WorkExperienceResponse] = [] + projects: list[ProjectResponse] = [] + # Habilidades - skills: List[SkillResponse] = [] - tools: List[ToolResponse] = [] - + skills: list[SkillResponse] = [] + tools: list[ToolResponse] = [] + # Formación - education: List[EducationResponse] = [] - additional_training: List[AdditionalTrainingResponse] = [] - certifications: List[CertificationResponse] = [] - + education: list[EducationResponse] = [] + additional_training: list[AdditionalTrainingResponse] = [] + certifications: list[CertificationResponse] = [] + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/education_schema.py b/app/api/schemas/education_schema.py index 8ba82c4..c781609 100644 --- a/app/api/schemas/education_schema.py +++ b/app/api/schemas/education_schema.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, Field, field_validator -from typing import Optional from datetime import date + +from pydantic import BaseModel, Field, field_validator + from app.api.schemas.common_schema import TimestampMixin @@ -9,74 +10,94 @@ class EducationBase(BaseModel): Formación académica formal del usuario. Representa estudios universitarios, ciclos formativos, etc. """ - institution: str = Field(..., min_length=1, description="Nombre de la institución (no puede estar vacía)") - degree: str = Field(..., min_length=1, description="Título obtenido o en curso (no puede estar vacío)") + + institution: str = Field( + ..., min_length=1, description="Nombre de la institución (no puede estar vacía)" + ) + degree: str = Field( + ..., + min_length=1, + description="Título obtenido o en curso (no puede estar vacío)", + ) start_date: date = Field(..., description="Fecha de inicio (obligatoria)") - end_date: Optional[date] = Field(None, description="Fecha de fin (opcional, None = en curso)") - description: Optional[str] = Field(None, description="Detalles adicionales (especialización, logros, etc.)") - order_index: int = Field(..., ge=0, description="Orden de aparición en el portafolio") + end_date: date | None = Field( + None, description="Fecha de fin (opcional, None = en curso)" + ) + description: str | None = Field( + None, description="Detalles adicionales (especialización, logros, etc.)" + ) + order_index: int = Field( + ..., ge=0, description="Orden de aparición en el portafolio" + ) - @field_validator('end_date') + @field_validator("end_date") @classmethod - def validate_end_date(cls, v: Optional[date], info) -> Optional[date]: + def validate_end_date(cls, v: date | None, info) -> date | None: """ Valida que endDate sea posterior a startDate si existe. - + Invariante: Si endDate existe, debe ser posterior a startDate. """ - if v is not None and 'start_date' in info.data: - start_date = info.data['start_date'] + if v is not None and "start_date" in info.data: + start_date = info.data["start_date"] if v <= start_date: - raise ValueError('end_date debe ser posterior a start_date') + raise ValueError("end_date debe ser posterior a start_date") return v class EducationCreate(EducationBase): """ Schema para crear formación académica. - + Invariantes: - institution no puede estar vacía - degree no puede estar vacío - startDate es obligatoria - Si endDate existe, debe ser posterior a startDate """ + pass class EducationUpdate(BaseModel): """ Schema para actualizar formación académica. - + Todos los campos son opcionales, pero institution y degree no pueden quedar vacíos si se actualizan. """ - institution: Optional[str] = Field(None, min_length=1) - degree: Optional[str] = Field(None, min_length=1) - start_date: Optional[date] = None - end_date: Optional[date] = None - description: Optional[str] = None - order_index: Optional[int] = Field(None, ge=0) - - @field_validator('end_date') + + institution: str | None = Field(None, min_length=1) + degree: str | None = Field(None, min_length=1) + start_date: date | None = None + end_date: date | None = None + description: str | None = None + order_index: int | None = Field(None, ge=0) + + @field_validator("end_date") @classmethod - def validate_end_date(cls, v: Optional[date], info) -> Optional[date]: + def validate_end_date(cls, v: date | None, info) -> date | None: """Valida que endDate sea posterior a startDate si ambos están presentes.""" - if v is not None and 'start_date' in info.data and info.data['start_date'] is not None: - if v <= info.data['start_date']: - raise ValueError('end_date debe ser posterior a start_date') + if ( + v is not None + and "start_date" in info.data + and info.data["start_date"] is not None + and v <= info.data["start_date"] + ): + raise ValueError("end_date debe ser posterior a start_date") return v class EducationResponse(EducationBase, TimestampMixin): """ Schema de respuesta de formación académica. - + Relaciones: - Pertenece a un único Profile - Un Profile tiene muchas Education """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/profile_schema.py b/app/api/schemas/profile_schema.py index cc6c3cd..53d9665 100644 --- a/app/api/schemas/profile_schema.py +++ b/app/api/schemas/profile_schema.py @@ -1,5 +1,6 @@ -from pydantic import BaseModel, Field, HttpUrl -from typing import Optional + +from pydantic import BaseModel, Field + from app.api.schemas.common_schema import TimestampMixin @@ -8,42 +9,55 @@ class ProfileBase(BaseModel): Perfil profesional del usuario. Representa la información personal y profesional visible en el portfolio. """ - full_name: str = Field(..., min_length=1, description="Nombre completo (no puede estar vacío)") - headline: str = Field(..., min_length=1, description="Título profesional o rol principal (no puede estar vacío)") - about: Optional[str] = Field(None, description="Descripción o resumen profesional") - location: Optional[str] = Field(None, description="Ubicación física o modalidad de trabajo") - profile_image: Optional[str] = Field(None, description="URL de la imagen de perfil") - banner_image: Optional[str] = Field(None, description="URL de la imagen de portada/banner") + + full_name: str = Field( + ..., min_length=1, description="Nombre completo (no puede estar vacío)" + ) + headline: str = Field( + ..., + min_length=1, + description="Título profesional o rol principal (no puede estar vacío)", + ) + about: str | None = Field(None, description="Descripción o resumen profesional") + location: str | None = Field( + None, description="Ubicación física o modalidad de trabajo" + ) + profile_image: str | None = Field(None, description="URL de la imagen de perfil") + banner_image: str | None = Field( + None, description="URL de la imagen de portada/banner" + ) class ProfileCreate(ProfileBase): """ Schema para crear perfil. - + Nota: Solo puede existir UN perfil activo en el sistema (invariante). """ + pass class ProfileUpdate(BaseModel): """ Schema para actualizar perfil. - + Todos los campos son opcionales excepto full_name y headline que no pueden quedar vacíos si se actualizan. """ - full_name: Optional[str] = Field(None, min_length=1) - headline: Optional[str] = Field(None, min_length=1) - about: Optional[str] = None - location: Optional[str] = None - profile_image: Optional[str] = None - banner_image: Optional[str] = None + + full_name: str | None = Field(None, min_length=1) + headline: str | None = Field(None, min_length=1) + about: str | None = None + location: str | None = None + profile_image: str | None = None + banner_image: str | None = None class ProfileResponse(ProfileBase, TimestampMixin): """ Schema de respuesta del perfil. - + Relaciones: - Tiene muchos Projects - Tiene muchas Education @@ -55,7 +69,8 @@ class ProfileResponse(ProfileBase, TimestampMixin): - Tiene muchas ContactMessage - Tiene muchas SocialNetwork """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/projects_schema.py b/app/api/schemas/projects_schema.py index 447fec0..00bda68 100644 --- a/app/api/schemas/projects_schema.py +++ b/app/api/schemas/projects_schema.py @@ -1,5 +1,6 @@ + from pydantic import BaseModel, Field, HttpUrl -from typing import Optional, List + from app.api.schemas.common_schema import TimestampMixin @@ -8,55 +9,75 @@ class ProjectBase(BaseModel): Proyecto desarrollado por el usuario. Representa un proyecto con detalles técnicos, funcionales y enlaces relevantes. """ - title: str = Field(..., min_length=1, description="Nombre del proyecto (no puede estar vacío)") - description: str = Field(..., min_length=1, description="Resumen del proyecto (no puede estar vacío)") - technologies: List[str] = Field(default_factory=list, description="Lista de tecnologías utilizadas") - repository_url: Optional[HttpUrl] = Field(None, description="Enlace al repositorio (GitHub, GitLab, etc.)") - live_demo_url: Optional[HttpUrl] = Field(None, description="Enlace a la demo en vivo (opcional)") - images: List[str] = Field(default_factory=list, description="Lista de URLs de imágenes del proyecto") - order_index: int = Field(..., ge=0, description="Orden de aparición en el portafolio (debe ser único dentro del perfil)") + + title: str = Field( + ..., min_length=1, description="Nombre del proyecto (no puede estar vacío)" + ) + description: str = Field( + ..., min_length=1, description="Resumen del proyecto (no puede estar vacío)" + ) + technologies: list[str] = Field( + default_factory=list, description="Lista de tecnologías utilizadas" + ) + repository_url: HttpUrl | None = Field( + None, description="Enlace al repositorio (GitHub, GitLab, etc.)" + ) + live_demo_url: HttpUrl | None = Field( + None, description="Enlace a la demo en vivo (opcional)" + ) + images: list[str] = Field( + default_factory=list, description="Lista de URLs de imágenes del proyecto" + ) + order_index: int = Field( + ..., + ge=0, + description="Orden de aparición en el portafolio (debe ser único dentro del perfil)", + ) class ProjectCreate(ProjectBase): """ Schema para crear proyecto. - + Invariantes: - title no puede estar vacío - description no puede estar vacía - orderIndex debe ser único dentro del perfil """ + pass class ProjectUpdate(BaseModel): """ Schema para actualizar proyecto. - + Todos los campos son opcionales, pero title y description no pueden quedar vacíos si se actualizan. """ - title: Optional[str] = Field(None, min_length=1) - description: Optional[str] = Field(None, min_length=1) - technologies: Optional[List[str]] = None - repository_url: Optional[HttpUrl] = None - live_demo_url: Optional[HttpUrl] = None - images: Optional[List[str]] = None - order_index: Optional[int] = Field(None, ge=0) + + title: str | None = Field(None, min_length=1) + description: str | None = Field(None, min_length=1) + technologies: list[str] | None = None + repository_url: HttpUrl | None = None + live_demo_url: HttpUrl | None = None + images: list[str] | None = None + order_index: int | None = Field(None, ge=0) class ProjectResponse(ProjectBase, TimestampMixin): """ Schema de respuesta de proyecto. - + Relaciones: - Pertenece a un único Profile - Un Profile tiene muchos Projects - + Invariantes: - orderIndex debe ser único dentro del perfil """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/skill_schema.py b/app/api/schemas/skill_schema.py index db4825f..86c2621 100644 --- a/app/api/schemas/skill_schema.py +++ b/app/api/schemas/skill_schema.py @@ -1,7 +1,8 @@ +from typing import Literal + from pydantic import BaseModel, Field -from typing import Optional, Literal -from app.api.schemas.common_schema import TimestampMixin +from app.api.schemas.common_schema import TimestampMixin # Niveles de dominio permitidos SkillLevel = Literal["beginner", "intermediate", "advanced", "expert"] @@ -16,7 +17,7 @@ "cloud", "testing", "design", - "other" + "other", ] @@ -25,41 +26,54 @@ class SkillBase(BaseModel): Habilidad técnica del usuario. Representa una tecnología con su nivel de dominio y categoría. """ - name: str = Field(..., min_length=1, description="Nombre de la tecnología (no puede estar vacío)") - level: SkillLevel = Field(..., description="Nivel de dominio (beginner, intermediate, advanced, expert)") - category: SkillCategory = Field(..., description="Categoría (backend, frontend, devops, etc.)") - order_index: int = Field(..., ge=0, description="Orden de aparición en el portafolio (debe ser único dentro del perfil)") + + name: str = Field( + ..., min_length=1, description="Nombre de la tecnología (no puede estar vacío)" + ) + level: SkillLevel = Field( + ..., description="Nivel de dominio (beginner, intermediate, advanced, expert)" + ) + category: SkillCategory = Field( + ..., description="Categoría (backend, frontend, devops, etc.)" + ) + order_index: int = Field( + ..., + ge=0, + description="Orden de aparición en el portafolio (debe ser único dentro del perfil)", + ) class SkillCreate(SkillBase): """ Schema para crear habilidad técnica. - + Invariantes: - name no puede estar vacío - level debe ser un valor permitido (beginner, intermediate, advanced, expert) - category debe ser un valor permitido - orderIndex debe ser único dentro del perfil """ + pass class SkillUpdate(BaseModel): """ Schema para actualizar habilidad técnica. - + Todos los campos son opcionales, pero name no puede quedar vacío si se actualiza. """ - name: Optional[str] = Field(None, min_length=1) - level: Optional[SkillLevel] = None - category: Optional[SkillCategory] = None - order_index: Optional[int] = Field(None, ge=0) + + name: str | None = Field(None, min_length=1) + level: SkillLevel | None = None + category: SkillCategory | None = None + order_index: int | None = Field(None, ge=0) class SkillResponse(SkillBase, TimestampMixin): """ Schema de respuesta de habilidad técnica. - + Relaciones: - Pertenece a un único Profile - Un Profile tiene muchas TechnicalSkill (Skills) @@ -67,11 +81,12 @@ class SkillResponse(SkillBase, TimestampMixin): * WorkExperience.technologies (dónde se usó) * AdditionalTraining.technologies (dónde se aprendió) * Projects.technologies (en qué proyectos) - + Invariantes: - orderIndex debe ser único dentro del perfil """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/social_networks_schema.py b/app/api/schemas/social_networks_schema.py index b2780ec..5c2bfef 100644 --- a/app/api/schemas/social_networks_schema.py +++ b/app/api/schemas/social_networks_schema.py @@ -1,15 +1,16 @@ +from typing import Literal + from pydantic import BaseModel, Field, HttpUrl -from typing import Optional, Literal -from app.api.schemas.common_schema import TimestampMixin +from app.api.schemas.common_schema import TimestampMixin # Plataformas de redes sociales más comunes SocialPlatform = Literal[ "github", "gitlab", "linkedin", - "twitter", # X (Twitter) - "x", # Alias para Twitter + "twitter", # X (Twitter) + "x", # Alias para Twitter "stackoverflow", "medium", "dev_to", @@ -19,9 +20,9 @@ "facebook", "discord", "telegram", - "website", # Sitio web personal - "blog", # Blog personal - "other" + "website", # Sitio web personal + "blog", # Blog personal + "other", ] @@ -30,48 +31,60 @@ class SocialNetworkBase(BaseModel): Enlace a una red social del usuario. Representa perfiles en plataformas sociales y profesionales. """ - platform: SocialPlatform = Field(..., description="Nombre de la red social (linkedin, github, twitter, etc.)") + + platform: SocialPlatform = Field( + ..., description="Nombre de la red social (linkedin, github, twitter, etc.)" + ) url: HttpUrl = Field(..., description="Enlace al perfil (debe ser válida)") - icon: Optional[str] = Field(None, description="Icono o identificador visual (URL, emoji, nombre del icono)") - order_index: int = Field(..., ge=0, description="Orden de aparición en el portafolio (debe ser único dentro del perfil)") + icon: str | None = Field( + None, description="Icono o identificador visual (URL, emoji, nombre del icono)" + ) + order_index: int = Field( + ..., + ge=0, + description="Orden de aparición en el portafolio (debe ser único dentro del perfil)", + ) class SocialNetworkCreate(SocialNetworkBase): """ Schema para crear red social. - + Invariantes: - platform no puede estar vacío - url debe ser válida - orderIndex debe ser único dentro del perfil """ + pass class SocialNetworkUpdate(BaseModel): """ Schema para actualizar red social. - + Todos los campos son opcionales, pero platform no puede quedar vacío si se actualiza. """ - platform: Optional[SocialPlatform] = None - url: Optional[HttpUrl] = None - icon: Optional[str] = None - order_index: Optional[int] = Field(None, ge=0) + + platform: SocialPlatform | None = None + url: HttpUrl | None = None + icon: str | None = None + order_index: int | None = Field(None, ge=0) class SocialNetworkResponse(SocialNetworkBase, TimestampMixin): """ Schema de respuesta de red social. - + Relaciones: - Pertenece a un único Profile - Un Profile tiene muchas SocialNetwork - + Invariantes: - orderIndex debe ser único dentro del perfil """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/tools_schema.py b/app/api/schemas/tools_schema.py index 2c85cf2..876704a 100644 --- a/app/api/schemas/tools_schema.py +++ b/app/api/schemas/tools_schema.py @@ -1,7 +1,8 @@ +from typing import Literal + from pydantic import BaseModel, Field -from typing import Optional, Literal -from app.api.schemas.common_schema import TimestampMixin +from app.api.schemas.common_schema import TimestampMixin # Niveles de conocimiento permitidos (opcional) ToolKnowledgeLevel = Literal["basic", "intermediate", "advanced", "expert"] @@ -19,7 +20,7 @@ "testing_tools", "monitoring", "containerization", - "other" + "other", ] @@ -28,48 +29,62 @@ class ToolBase(BaseModel): Herramienta de desarrollo utilizada por el usuario. Representa IDE, plataformas, servicios, software, etc. """ - name: str = Field(..., min_length=1, description="Nombre de la herramienta (no puede estar vacío)") - category: ToolCategory = Field(..., description="Tipo de herramienta (IDE, cloud, CI/CD, etc.)") - knowledge_level: Optional[ToolKnowledgeLevel] = Field(None, description="Nivel de conocimiento (opcional)") - order_index: int = Field(..., ge=0, description="Orden de aparición en el portafolio (debe ser único dentro del perfil)") + + name: str = Field( + ..., min_length=1, description="Nombre de la herramienta (no puede estar vacío)" + ) + category: ToolCategory = Field( + ..., description="Tipo de herramienta (IDE, cloud, CI/CD, etc.)" + ) + knowledge_level: ToolKnowledgeLevel | None = Field( + None, description="Nivel de conocimiento (opcional)" + ) + order_index: int = Field( + ..., + ge=0, + description="Orden de aparición en el portafolio (debe ser único dentro del perfil)", + ) class ToolCreate(ToolBase): """ Schema para crear herramienta. - + Invariantes: - name no puede estar vacío - category debe ser un valor permitido - orderIndex debe ser único dentro del perfil """ + pass class ToolUpdate(BaseModel): """ Schema para actualizar herramienta. - + Todos los campos son opcionales, pero name no puede quedar vacío si se actualiza. """ - name: Optional[str] = Field(None, min_length=1) - category: Optional[ToolCategory] = None - knowledge_level: Optional[ToolKnowledgeLevel] = None - order_index: Optional[int] = Field(None, ge=0) + + name: str | None = Field(None, min_length=1) + category: ToolCategory | None = None + knowledge_level: ToolKnowledgeLevel | None = None + order_index: int | None = Field(None, ge=0) class ToolResponse(ToolBase, TimestampMixin): """ Schema de respuesta de herramienta. - + Relaciones: - Pertenece a un único Profile - Un Profile tiene muchas Tool - + Invariantes: - orderIndex debe ser único dentro del perfil """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/schemas/work_experience_schema.py b/app/api/schemas/work_experience_schema.py index 2132e72..9e83a04 100644 --- a/app/api/schemas/work_experience_schema.py +++ b/app/api/schemas/work_experience_schema.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, Field, field_validator -from typing import Optional, List from datetime import date + +from pydantic import BaseModel, Field, field_validator + from app.api.schemas.common_schema import TimestampMixin @@ -9,34 +10,51 @@ class WorkExperienceBase(BaseModel): Experiencia laboral dentro del perfil profesional del usuario. Incluye información sobre el cargo, empresa, fechas y responsabilidades. """ - role: str = Field(..., min_length=1, description="Cargo desempeñado (no puede estar vacío)") - company: str = Field(..., min_length=1, description="Empresa donde se trabajó (no puede estar vacía)") - location: Optional[str] = Field(None, description="Ubicación o modalidad (remoto, ciudad, híbrido)") + + role: str = Field( + ..., min_length=1, description="Cargo desempeñado (no puede estar vacío)" + ) + company: str = Field( + ..., min_length=1, description="Empresa donde se trabajó (no puede estar vacía)" + ) + location: str | None = Field( + None, description="Ubicación o modalidad (remoto, ciudad, híbrido)" + ) start_date: date = Field(..., description="Fecha de inicio (obligatoria)") - end_date: Optional[date] = Field(None, description="Fecha de fin (opcional, None = actualmente trabajando)") - description: Optional[str] = Field(None, description="Resumen de tareas, responsabilidades o logros") - technologies: List[str] = Field(default_factory=list, description="Lista de tecnologías usadas") - order_index: int = Field(..., ge=0, description="Orden de aparición en el CV (debe ser único dentro del perfil)") + end_date: date | None = Field( + None, description="Fecha de fin (opcional, None = actualmente trabajando)" + ) + description: str | None = Field( + None, description="Resumen de tareas, responsabilidades o logros" + ) + technologies: list[str] = Field( + default_factory=list, description="Lista de tecnologías usadas" + ) + order_index: int = Field( + ..., + ge=0, + description="Orden de aparición en el CV (debe ser único dentro del perfil)", + ) - @field_validator('end_date') + @field_validator("end_date") @classmethod - def validate_end_date(cls, v: Optional[date], info) -> Optional[date]: + def validate_end_date(cls, v: date | None, info) -> date | None: """ Valida que endDate sea posterior a startDate si existe. - + Invariante: Si endDate existe, debe ser posterior a startDate. """ - if v is not None and 'start_date' in info.data: - start_date = info.data['start_date'] + if v is not None and "start_date" in info.data: + start_date = info.data["start_date"] if v <= start_date: - raise ValueError('end_date debe ser posterior a start_date') + raise ValueError("end_date debe ser posterior a start_date") return v class WorkExperienceCreate(WorkExperienceBase): """ Schema para crear experiencia laboral. - + Invariantes: - role no puede estar vacío - company no puede estar vacía @@ -44,48 +62,55 @@ class WorkExperienceCreate(WorkExperienceBase): - Si endDate existe, debe ser posterior a startDate - orderIndex debe ser único dentro del perfil """ + pass class WorkExperienceUpdate(BaseModel): """ Schema para actualizar experiencia laboral. - + Todos los campos son opcionales, pero role y company no pueden quedar vacíos si se actualizan. """ - role: Optional[str] = Field(None, min_length=1) - company: Optional[str] = Field(None, min_length=1) - location: Optional[str] = None - start_date: Optional[date] = None - end_date: Optional[date] = None - description: Optional[str] = None - technologies: Optional[List[str]] = None - order_index: Optional[int] = Field(None, ge=0) - - @field_validator('end_date') + + role: str | None = Field(None, min_length=1) + company: str | None = Field(None, min_length=1) + location: str | None = None + start_date: date | None = None + end_date: date | None = None + description: str | None = None + technologies: list[str] | None = None + order_index: int | None = Field(None, ge=0) + + @field_validator("end_date") @classmethod - def validate_end_date(cls, v: Optional[date], info) -> Optional[date]: + def validate_end_date(cls, v: date | None, info) -> date | None: """Valida que endDate sea posterior a startDate si ambos están presentes.""" - if v is not None and 'start_date' in info.data and info.data['start_date'] is not None: - if v <= info.data['start_date']: - raise ValueError('end_date debe ser posterior a start_date') + if ( + v is not None + and "start_date" in info.data + and info.data["start_date"] is not None + and v <= info.data["start_date"] + ): + raise ValueError("end_date debe ser posterior a start_date") return v class WorkExperienceResponse(WorkExperienceBase, TimestampMixin): """ Schema de respuesta de experiencia laboral. - + Relaciones: - Pertenece a un único Profile - Un Profile tiene muchas WorkExperience - technologies se relaciona conceptualmente con Skills - + Invariantes: - orderIndex debe ser único dentro del mismo perfil """ + id: str - + class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/app/api/v1/router.py b/app/api/v1/router.py index abbaa4d..e6a2b5f 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,18 +1,19 @@ from fastapi import APIRouter + from app.api.v1.routers import ( + additional_training_router, + certification_router, + contact_info_router, + contact_messages_router, + cv_router, + education_router, health_router, profile_router, - contact_info_router, - social_networks_router, projects_router, - work_experience_router, skill_router, + social_networks_router, tools_router, - education_router, - additional_training_router, - certification_router, - contact_messages_router, - cv_router + work_experience_router, ) # Router principal de la versión 1 de la API @@ -43,4 +44,4 @@ api_v1_router.include_router(contact_messages_router.router) # ===== CV COMPLETO ===== -api_v1_router.include_router(cv_router.router) \ No newline at end of file +api_v1_router.include_router(cv_router.router) diff --git a/app/api/v1/routers/additional_training_router.py b/app/api/v1/routers/additional_training_router.py index 56f2933..fc32e74 100644 --- a/app/api/v1/routers/additional_training_router.py +++ b/app/api/v1/routers/additional_training_router.py @@ -1,11 +1,11 @@ +from datetime import date, datetime + from fastapi import APIRouter, HTTPException, status -from typing import List -from datetime import datetime, date from app.api.schemas.additional_training_schema import ( - AdditionalTrainingResponse, AdditionalTrainingCreate, - AdditionalTrainingUpdate + AdditionalTrainingResponse, + AdditionalTrainingUpdate, ) from app.api.schemas.common_schema import MessageResponse @@ -24,7 +24,7 @@ technologies=["Python", "FastAPI", "Design Patterns", "SOLID", "DDD"], order_index=1, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), AdditionalTrainingResponse( id="train_002", @@ -37,7 +37,7 @@ technologies=["React", "TypeScript", "JavaScript", "Performance Optimization"], order_index=2, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), AdditionalTrainingResponse( id="train_003", @@ -50,7 +50,7 @@ technologies=["Docker", "Kubernetes", "CI/CD", "DevOps"], order_index=3, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), AdditionalTrainingResponse( id="train_004", @@ -63,7 +63,7 @@ technologies=["MongoDB", "NoSQL", "Database Design"], order_index=0, # Más reciente created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), AdditionalTrainingResponse( id="train_005", @@ -76,7 +76,7 @@ technologies=["Python", "Pytest", "TDD", "Testing"], order_index=4, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), AdditionalTrainingResponse( id="train_006", @@ -89,31 +89,31 @@ technologies=["JavaScript", "Node.js", "React", "MongoDB", "Express"], order_index=5, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ] @router.get( "", - response_model=List[AdditionalTrainingResponse], + response_model=list[AdditionalTrainingResponse], summary="Listar formación adicional", - description="Obtiene toda la formación complementaria ordenada por orderIndex" + description="Obtiene toda la formación complementaria ordenada por orderIndex", ) async def get_additional_trainings(): """ Lista toda la formación adicional del perfil único del sistema. - + La formación se retorna ordenada por `order_index` ascendente. Típicamente, la formación más reciente tiene order_index menor. - + Returns: List[AdditionalTrainingResponse]: Lista de formación adicional ordenada - + Relación: - Toda la formación pertenece al Profile único del sistema - technologies se relaciona con Skills del perfil - + TODO: Implementar con GetAdditionalTrainingsUseCase TODO: Ordenar por order_index ASC (más reciente primero) TODO: Considerar ordenar también por date DESC como criterio secundario @@ -125,30 +125,30 @@ async def get_additional_trainings(): "/{training_id}", response_model=AdditionalTrainingResponse, summary="Obtener formación adicional", - description="Obtiene una formación adicional específica por ID" + description="Obtiene una formación adicional específica por ID", ) async def get_additional_training(training_id: str): """ Obtiene una formación adicional por su ID. - + Args: training_id: ID único de la formación - + Returns: AdditionalTrainingResponse: Formación encontrada - + Raises: HTTPException 404: Si la formación no existe - + TODO: Implementar con GetAdditionalTrainingUseCase """ for train in MOCK_TRAININGS: if train.id == training_id: return train - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Formación adicional con ID '{training_id}' no encontrada" + detail=f"Formación adicional con ID '{training_id}' no encontrada", ) @@ -157,31 +157,31 @@ async def get_additional_training(training_id: str): response_model=AdditionalTrainingResponse, status_code=status.HTTP_201_CREATED, summary="Crear formación adicional", - description="Crea una nueva formación adicional asociada al perfil" + description="Crea una nueva formación adicional asociada al perfil", ) -async def create_additional_training(training_data: AdditionalTrainingCreate): +async def create_additional_training(_training_data: AdditionalTrainingCreate): """ Crea una nueva formación adicional y la asocia al perfil único del sistema. - + **Invariantes que se validan automáticamente:** - `title` no puede estar vacío (min_length=1) - `institution` no puede estar vacía (min_length=1) - `date` es obligatoria - `duration_hours` si se proporciona, debe ser >= 1 - + Args: training_data: Datos de la formación a crear - + Returns: AdditionalTrainingResponse: Formación creada - + Raises: HTTPException 422: Si los datos no cumplen las invariantes - + Nota sobre technologies: - Las tecnologías listadas deberían idealmente existir como Skills en el perfil - Esto ayuda a vincular la formación con las habilidades adquiridas - + TODO: Implementar con CreateAdditionalTrainingUseCase TODO: Considerar auto-incrementar orderIndex si no se proporciona TODO: Validar que las technologies existan como Skills (opcional) @@ -194,41 +194,40 @@ async def create_additional_training(training_data: AdditionalTrainingCreate): "/{training_id}", response_model=AdditionalTrainingResponse, summary="Actualizar formación adicional", - description="Actualiza una formación adicional existente" + description="Actualiza una formación adicional existente", ) async def update_additional_training( - training_id: str, - training_data: AdditionalTrainingUpdate + training_id: str, _training_data: AdditionalTrainingUpdate ): """ Actualiza una formación adicional existente. - + **Invariantes:** - Si se actualiza `title`, no puede estar vacío - Si se actualiza `institution`, no puede estar vacía - Si se actualiza `duration_hours`, debe ser >= 1 - + Args: training_id: ID de la formación a actualizar training_data: Datos a actualizar (campos opcionales) - + Returns: AdditionalTrainingResponse: Formación actualizada - + Raises: HTTPException 404: Si la formación no existe HTTPException 422: Si los datos no cumplen las invariantes - + TODO: Implementar con UpdateAdditionalTrainingUseCase TODO: Requiere autenticación de admin """ for train in MOCK_TRAININGS: if train.id == training_id: return train - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Formación adicional con ID '{training_id}' no encontrada" + detail=f"Formación adicional con ID '{training_id}' no encontrada", ) @@ -236,46 +235,46 @@ async def update_additional_training( "/{training_id}", response_model=MessageResponse, summary="Eliminar formación adicional", - description="Elimina una formación adicional del perfil" + description="Elimina una formación adicional del perfil", ) async def delete_additional_training(training_id: str): """ Elimina una formación adicional del perfil. - + Nota: Al eliminar una formación, puede ser necesario reordenar los orderIndex de la formación restante para mantener la coherencia. - + Args: training_id: ID de la formación a eliminar - + Returns: MessageResponse: Confirmación de eliminación - + Raises: HTTPException 404: Si la formación no existe - + TODO: Implementar con DeleteAdditionalTrainingUseCase TODO: Considerar reordenamiento automático de orderIndex TODO: Requiere autenticación de admin """ return MessageResponse( success=True, - message=f"Formación adicional '{training_id}' eliminada correctamente" + message=f"Formación adicional '{training_id}' eliminada correctamente", ) @router.patch( "/reorder", - response_model=List[AdditionalTrainingResponse], + response_model=list[AdditionalTrainingResponse], summary="Reordenar formación adicional", - description="Actualiza el orderIndex de múltiples formaciones de una vez" + description="Actualiza el orderIndex de múltiples formaciones de una vez", ) -async def reorder_additional_trainings(training_orders: List[dict]): +async def reorder_additional_trainings(_training_orders: list[dict]): """ Reordena múltiples formaciones adicionales de una sola vez. - + Útil para drag & drop en el panel de administración. - + Args: training_orders: Lista de objetos con {id, orderIndex} Ejemplo: [ @@ -283,14 +282,14 @@ async def reorder_additional_trainings(training_orders: List[dict]): {"id": "train_002", "orderIndex": 1}, {"id": "train_003", "orderIndex": 0} ] - + Returns: List[AdditionalTrainingResponse]: Formaciones reordenadas - + Raises: HTTPException 400: Si hay orderIndex duplicados HTTPException 404: Si algún training_id no existe - + TODO: Implementar con ReorderAdditionalTrainingsUseCase TODO: Validar que todos los orderIndex sean únicos TODO: Validar que todos los training_id existan @@ -302,34 +301,35 @@ async def reorder_additional_trainings(training_orders: List[dict]): @router.get( "/by-technology/{technology}", - response_model=List[AdditionalTrainingResponse], + response_model=list[AdditionalTrainingResponse], summary="Filtrar formación por tecnología", - description="Obtiene formaciones que incluyan una tecnología específica" + description="Obtiene formaciones que incluyan una tecnología específica", ) async def get_trainings_by_technology(technology: str): """ Filtra formaciones adicionales por tecnología aprendida. - + Útil para mostrar qué cursos/formaciones se hicieron para aprender una tecnología específica. - + Args: technology: Nombre de la tecnología (case-insensitive) - + Returns: List[AdditionalTrainingResponse]: Formaciones que incluyen esa tecnología - + Relación con Skills: - Este endpoint ayuda a vincular formación con habilidades adquiridas - Las tecnologías deberían coincidir con Skills del perfil - + TODO: Implementar con GetTrainingsByTechnologyUseCase TODO: Hacer búsqueda case-insensitive TODO: Considerar búsqueda parcial (contains) """ tech_lower = technology.lower() filtered = [ - t for t in MOCK_TRAININGS + t + for t in MOCK_TRAININGS if any(tech.lower() == tech_lower for tech in t.technologies) ] - return sorted(filtered, key=lambda x: x.order_index) \ No newline at end of file + return sorted(filtered, key=lambda x: x.order_index) diff --git a/app/api/v1/routers/certification_router.py b/app/api/v1/routers/certification_router.py index 0fc270a..43cb966 100644 --- a/app/api/v1/routers/certification_router.py +++ b/app/api/v1/routers/certification_router.py @@ -1,11 +1,11 @@ +from datetime import date, datetime + from fastapi import APIRouter, HTTPException, status -from typing import List -from datetime import datetime, date from app.api.schemas.certification_schema import ( - CertificationResponse, CertificationCreate, - CertificationUpdate + CertificationResponse, + CertificationUpdate, ) from app.api.schemas.common_schema import MessageResponse @@ -23,7 +23,7 @@ credential_url="https://www.credly.com/badges/aws-saa-123456789", order_index=1, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), CertificationResponse( id="cert_002", @@ -35,7 +35,7 @@ credential_url="https://university.mongodb.com/certification/certificate/987654321", order_index=0, # Más reciente created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), CertificationResponse( id="cert_003", @@ -47,7 +47,7 @@ credential_url="https://www.scrum.org/certificates/555666777", order_index=3, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), CertificationResponse( id="cert_004", @@ -59,7 +59,7 @@ credential_url="https://www.credly.com/badges/az900-111222333", order_index=2, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), CertificationResponse( id="cert_005", @@ -71,46 +71,47 @@ credential_url="https://credentials.docker.com/444555666", order_index=4, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ] @router.get( "", - response_model=List[CertificationResponse], + response_model=list[CertificationResponse], summary="Listar certificaciones", - description="Obtiene todas las certificaciones ordenadas por orderIndex" + description="Obtiene todas las certificaciones ordenadas por orderIndex", ) async def get_certifications(active_only: bool = False): """ Lista todas las certificaciones del perfil único del sistema. - + Las certificaciones se retornan ordenadas por `order_index` ascendente. Típicamente, las certificaciones más recientes o vigentes tienen order_index menor. - + Args: active_only: Si es True, solo retorna certificaciones vigentes (no expiradas) - + Returns: List[CertificationResponse]: Lista de certificaciones ordenadas - + Relación: - Todas las certificaciones pertenecen al Profile único del sistema - + TODO: Implementar con GetCertificationsUseCase TODO: Ordenar por order_index ASC (vigentes primero, luego más recientes) TODO: Filtrar por vigencia si active_only=True """ certifications = MOCK_CERTIFICATIONS - + if active_only: today = date.today() certifications = [ - cert for cert in certifications + cert + for cert in certifications if cert.expiration_date is None or cert.expiration_date > today ] - + return sorted(certifications, key=lambda x: x.order_index) @@ -118,30 +119,30 @@ async def get_certifications(active_only: bool = False): "/{certification_id}", response_model=CertificationResponse, summary="Obtener certificación", - description="Obtiene una certificación específica por ID" + description="Obtiene una certificación específica por ID", ) async def get_certification(certification_id: str): """ Obtiene una certificación por su ID. - + Args: certification_id: ID único de la certificación - + Returns: CertificationResponse: Certificación encontrada - + Raises: HTTPException 404: Si la certificación no existe - + TODO: Implementar con GetCertificationUseCase """ for cert in MOCK_CERTIFICATIONS: if cert.id == certification_id: return cert - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Certificación con ID '{certification_id}' no encontrada" + detail=f"Certificación con ID '{certification_id}' no encontrada", ) @@ -150,30 +151,30 @@ async def get_certification(certification_id: str): response_model=CertificationResponse, status_code=status.HTTP_201_CREATED, summary="Crear certificación", - description="Crea una nueva certificación asociada al perfil" + description="Crea una nueva certificación asociada al perfil", ) -async def create_certification(certification_data: CertificationCreate): +async def create_certification(_certification_data: CertificationCreate): """ Crea una nueva certificación y la asocia al perfil único del sistema. - + **Invariantes que se validan automáticamente:** - `name` no puede estar vacío (min_length=1) - `issuer` no puede estar vacío (min_length=1) - `issueDate` es obligatoria - + Args: certification_data: Datos de la certificación a crear - + Returns: CertificationResponse: Certificación creada - + Raises: HTTPException 422: Si los datos no cumplen las invariantes - + Notas: - Si la certificación no expira, dejar `expirationDate` como None - `credentialUrl` debe ser una URL válida si se proporciona - + TODO: Implementar con CreateCertificationUseCase TODO: Considerar auto-incrementar orderIndex si no se proporciona TODO: Validar formato de credentialId según issuer (opcional) @@ -186,45 +187,44 @@ async def create_certification(certification_data: CertificationCreate): "/{certification_id}", response_model=CertificationResponse, summary="Actualizar certificación", - description="Actualiza una certificación existente" + description="Actualiza una certificación existente", ) async def update_certification( - certification_id: str, - certification_data: CertificationUpdate + certification_id: str, _certification_data: CertificationUpdate ): """ Actualiza una certificación existente. - + **Invariantes:** - Si se actualiza `name`, no puede estar vacío - Si se actualiza `issuer`, no puede estar vacío - Si se actualiza `credentialUrl`, debe ser una URL válida - + Args: certification_id: ID de la certificación a actualizar certification_data: Datos a actualizar (campos opcionales) - + Returns: CertificationResponse: Certificación actualizada - + Raises: HTTPException 404: Si la certificación no existe HTTPException 422: Si los datos no cumplen las invariantes - + Casos de uso comunes: - Actualizar expirationDate cuando se renueva la certificación - Actualizar credentialUrl si cambia el sistema de verificación - + TODO: Implementar con UpdateCertificationUseCase TODO: Requiere autenticación de admin """ for cert in MOCK_CERTIFICATIONS: if cert.id == certification_id: return cert - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Certificación con ID '{certification_id}' no encontrada" + detail=f"Certificación con ID '{certification_id}' no encontrada", ) @@ -232,46 +232,46 @@ async def update_certification( "/{certification_id}", response_model=MessageResponse, summary="Eliminar certificación", - description="Elimina una certificación del perfil" + description="Elimina una certificación del perfil", ) async def delete_certification(certification_id: str): """ Elimina una certificación del perfil. - + Nota: Al eliminar una certificación, puede ser necesario reordenar los orderIndex de las certificaciones restantes para mantener la coherencia. - + Args: certification_id: ID de la certificación a eliminar - + Returns: MessageResponse: Confirmación de eliminación - + Raises: HTTPException 404: Si la certificación no existe - + TODO: Implementar con DeleteCertificationUseCase TODO: Considerar reordenamiento automático de orderIndex TODO: Requiere autenticación de admin """ return MessageResponse( success=True, - message=f"Certificación '{certification_id}' eliminada correctamente" + message=f"Certificación '{certification_id}' eliminada correctamente", ) @router.patch( "/reorder", - response_model=List[CertificationResponse], + response_model=list[CertificationResponse], summary="Reordenar certificaciones", - description="Actualiza el orderIndex de múltiples certificaciones de una vez" + description="Actualiza el orderIndex de múltiples certificaciones de una vez", ) -async def reorder_certifications(certification_orders: List[dict]): +async def reorder_certifications(_certification_orders: list[dict]): """ Reordena múltiples certificaciones de una sola vez. - + Útil para drag & drop en el panel de administración. - + Args: certification_orders: Lista de objetos con {id, orderIndex} Ejemplo: [ @@ -279,14 +279,14 @@ async def reorder_certifications(certification_orders: List[dict]): {"id": "cert_002", "orderIndex": 1}, {"id": "cert_003", "orderIndex": 0} ] - + Returns: List[CertificationResponse]: Certificaciones reordenadas - + Raises: HTTPException 400: Si hay orderIndex duplicados HTTPException 404: Si algún certification_id no existe - + TODO: Implementar con ReorderCertificationsUseCase TODO: Validar que todos los orderIndex sean únicos TODO: Validar que todos los certification_id existan @@ -298,58 +298,58 @@ async def reorder_certifications(certification_orders: List[dict]): @router.get( "/by-issuer/{issuer}", - response_model=List[CertificationResponse], + response_model=list[CertificationResponse], summary="Filtrar certificaciones por emisor", - description="Obtiene certificaciones de un emisor específico" + description="Obtiene certificaciones de un emisor específico", ) async def get_certifications_by_issuer(issuer: str): """ Filtra certificaciones por entidad emisora. - + Útil para agrupar certificaciones del mismo proveedor (ej: todas las certificaciones AWS, todas las de Microsoft). - + Args: issuer: Nombre del emisor (búsqueda case-insensitive) - + Returns: List[CertificationResponse]: Certificaciones del emisor - + TODO: Implementar con GetCertificationsByIssuerUseCase TODO: Hacer búsqueda case-insensitive y parcial (contains) """ issuer_lower = issuer.lower() filtered = [ - cert for cert in MOCK_CERTIFICATIONS - if issuer_lower in cert.issuer.lower() + cert for cert in MOCK_CERTIFICATIONS if issuer_lower in cert.issuer.lower() ] return sorted(filtered, key=lambda x: x.order_index) @router.get( "/status/expired", - response_model=List[CertificationResponse], + response_model=list[CertificationResponse], summary="Listar certificaciones expiradas", - description="Obtiene certificaciones que ya expiraron" + description="Obtiene certificaciones que ya expiraron", ) async def get_expired_certifications(): """ Lista certificaciones que ya han expirado. - + Útil para identificar qué certificaciones necesitan renovación. - + Returns: List[CertificationResponse]: Certificaciones expiradas - + Nota: - Las certificaciones sin expirationDate (None) nunca se consideran expiradas - + TODO: Implementar con GetExpiredCertificationsUseCase TODO: Ordenar por fecha de expiración (más reciente primero) """ today = date.today() expired = [ - cert for cert in MOCK_CERTIFICATIONS + cert + for cert in MOCK_CERTIFICATIONS if cert.expiration_date is not None and cert.expiration_date < today ] return sorted(expired, key=lambda x: x.expiration_date or date.max, reverse=True) @@ -357,37 +357,38 @@ async def get_expired_certifications(): @router.get( "/status/expiring-soon", - response_model=List[CertificationResponse], + response_model=list[CertificationResponse], summary="Listar certificaciones próximas a expirar", - description="Obtiene certificaciones que expiran en los próximos N días" + description="Obtiene certificaciones que expiran en los próximos N días", ) async def get_expiring_soon_certifications(days: int = 90): """ Lista certificaciones que expiran pronto. - + Útil para mostrar alertas de renovación en el admin panel. - + Args: days: Número de días hacia el futuro para considerar (default: 90) - + Returns: List[CertificationResponse]: Certificaciones que expiran pronto - + Ejemplo: - GET /certifications/status/expiring-soon?days=30 (certificaciones que expiran en los próximos 30 días) - + TODO: Implementar con GetExpiringSoonCertificationsUseCase TODO: Ordenar por fecha de expiración (más cercana primero) """ from datetime import timedelta - + today = date.today() threshold = today + timedelta(days=days) - + expiring = [ - cert for cert in MOCK_CERTIFICATIONS + cert + for cert in MOCK_CERTIFICATIONS if cert.expiration_date is not None and today < cert.expiration_date <= threshold ] - return sorted(expiring, key=lambda x: x.expiration_date or date.max) \ No newline at end of file + return sorted(expiring, key=lambda x: x.expiration_date or date.max) diff --git a/app/api/v1/routers/contact_info_router.py b/app/api/v1/routers/contact_info_router.py index 10c9709..4937030 100644 --- a/app/api/v1/routers/contact_info_router.py +++ b/app/api/v1/routers/contact_info_router.py @@ -1,12 +1,13 @@ -from fastapi import APIRouter, HTTPException, status from datetime import datetime +from fastapi import APIRouter, status + +from app.api.schemas.common_schema import MessageResponse from app.api.schemas.contact_info_schema import ( - ContactInformationResponse, ContactInformationCreate, - ContactInformationUpdate + ContactInformationResponse, + ContactInformationUpdate, ) -from app.api.schemas.common_schema import MessageResponse router = APIRouter(prefix="/contact-information", tags=["Contact Information"]) @@ -18,7 +19,7 @@ location="Valencia, España", website="https://juanperez.dev", created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ) @@ -26,21 +27,21 @@ "", response_model=ContactInformationResponse, summary="Obtener información de contacto", - description="Obtiene la información de contacto pública del perfil" + description="Obtiene la información de contacto pública del perfil", ) async def get_contact_information(): """ Obtiene la información de contacto del perfil único del sistema. - + **Invariante**: Solo existe UNA información de contacto por perfil (relación 1-a-1). - + Returns: ContactInformationResponse: Información de contacto del perfil - + Relación: - Pertenece al Profile único del sistema - Relación 1-a-1 (un perfil tiene una sola info de contacto) - + TODO: Implementar con GetContactInformationUseCase """ return MOCK_CONTACT_INFO @@ -50,26 +51,26 @@ async def get_contact_information(): "", response_model=ContactInformationResponse, summary="Actualizar información de contacto", - description="Actualiza la información de contacto del perfil" + description="Actualiza la información de contacto del perfil", ) -async def update_contact_information(contact_data: ContactInformationUpdate): +async def update_contact_information(_contact_data: ContactInformationUpdate): """ Actualiza la información de contacto del perfil. - + **Invariante**: Solo puede haber UNA información de contacto por perfil. - + Este endpoint actualiza el registro único existente, no crea uno nuevo. - + Args: contact_data: Datos a actualizar (campos opcionales) - + Returns: ContactInformationResponse: Información de contacto actualizada - + Raises: HTTPException 404: Si no existe información de contacto HTTPException 422: Si email no es válido - + TODO: Implementar con UpdateContactInformationUseCase TODO: Requiere autenticación de admin """ @@ -81,27 +82,27 @@ async def update_contact_information(contact_data: ContactInformationUpdate): response_model=ContactInformationResponse, status_code=status.HTTP_201_CREATED, summary="Crear información de contacto inicial", - description="Crea la información de contacto del perfil (solo si no existe)" + description="Crea la información de contacto del perfil (solo si no existe)", ) -async def create_contact_information(contact_data: ContactInformationCreate): +async def create_contact_information(_contact_data: ContactInformationCreate): """ Crea la información de contacto inicial del perfil. - + **Invariante crítico**: Solo puede existir UNA información de contacto por perfil. - + Este endpoint solo debe ejecutarse UNA VEZ en la vida del sistema, durante la configuración inicial del perfil. - + Args: contact_data: Datos de la información de contacto - + Returns: ContactInformationResponse: Información de contacto creada - + Raises: HTTPException 409: Si ya existe información de contacto en el perfil HTTPException 422: Si email no es válido - + TODO: Implementar con CreateContactInformationUseCase TODO: Validar que NO exista ya información de contacto antes de crear TODO: Requiere autenticación de admin @@ -111,7 +112,7 @@ async def create_contact_information(contact_data: ContactInformationCreate): # status_code=status.HTTP_409_CONFLICT, # detail="Ya existe información de contacto para este perfil. Solo puede haber una." # ) - + return MOCK_CONTACT_INFO @@ -119,29 +120,28 @@ async def create_contact_information(contact_data: ContactInformationCreate): "", response_model=MessageResponse, summary="Eliminar información de contacto (PELIGROSO)", - description="Elimina la información de contacto del perfil" + description="Elimina la información de contacto del perfil", ) async def delete_contact_information(): """ Elimina la información de contacto del perfil. - + ⚠️ **ENDPOINT PELIGROSO**: Esto eliminará toda la información de contacto. - + Dado que la información de contacto tiene una relación 1-a-1 con Profile, eliminarla dejará el perfil sin datos de contacto públicos. - + Returns: MessageResponse: Confirmación de eliminación - + Raises: HTTPException 404: Si no existe información de contacto - + TODO: Implementar con DeleteContactInformationUseCase TODO: Considerar soft delete en vez de hard delete TODO: Requiere autenticación de admin TODO: Considerar si debe permitirse eliminar o solo actualizar """ return MessageResponse( - success=True, - message="Información de contacto eliminada correctamente" - ) \ No newline at end of file + success=True, message="Información de contacto eliminada correctamente" + ) diff --git a/app/api/v1/routers/contact_messages_router.py b/app/api/v1/routers/contact_messages_router.py index 8f643c1..7537602 100644 --- a/app/api/v1/routers/contact_messages_router.py +++ b/app/api/v1/routers/contact_messages_router.py @@ -1,13 +1,13 @@ -from fastapi import APIRouter, HTTPException, status, Request -from typing import List from datetime import datetime +from typing import Any +from fastapi import APIRouter, HTTPException, Request, status + +from app.api.schemas.common_schema import MessageResponse from app.api.schemas.contact_messages_schema import ( - ContactMessageResponse, ContactMessageCreate, - ContactMessageUpdate + ContactMessageResponse, ) -from app.api.schemas.common_schema import MessageResponse router = APIRouter(prefix="/contact-messages", tags=["Contact Messages"]) @@ -20,7 +20,7 @@ message="Hola Juan, me gustaría contactarte para una oportunidad laboral en nuestra empresa. ¿Podrías enviarme tu disponibilidad para una llamada?", sent_at=datetime(2025, 1, 18, 10, 30, 0), created_at=datetime(2025, 1, 18, 10, 30, 0), - updated_at=datetime(2025, 1, 18, 10, 30, 0) + updated_at=datetime(2025, 1, 18, 10, 30, 0), ), ContactMessageResponse( id="msg_002", @@ -29,7 +29,7 @@ message="Vi tu portfolio y me impresionó tu experiencia con Clean Architecture. Estamos buscando un desarrollador senior para nuestro equipo. ¿Te interesaría charlar?", sent_at=datetime(2025, 1, 17, 15, 45, 0), created_at=datetime(2025, 1, 17, 15, 45, 0), - updated_at=datetime(2025, 1, 17, 15, 45, 0) + updated_at=datetime(2025, 1, 17, 15, 45, 0), ), ContactMessageResponse( id="msg_003", @@ -38,7 +38,7 @@ message="Hola, estamos organizando una conferencia sobre arquitecturas de software y nos gustaría invitarte como speaker. ¿Estarías interesado?", sent_at=datetime(2025, 1, 16, 9, 20, 0), created_at=datetime(2025, 1, 16, 9, 20, 0), - updated_at=datetime(2025, 1, 16, 9, 20, 0) + updated_at=datetime(2025, 1, 16, 9, 20, 0), ), ContactMessageResponse( id="msg_004", @@ -47,7 +47,7 @@ message="Me gustaría colaborar contigo en un proyecto. Tengo una idea para una aplicación y creo que tu expertise sería perfecta. ¿Hacemos una videollamada?", sent_at=datetime(2025, 1, 15, 14, 10, 0), created_at=datetime(2025, 1, 15, 14, 10, 0), - updated_at=datetime(2025, 1, 15, 14, 10, 0) + updated_at=datetime(2025, 1, 15, 14, 10, 0), ), ContactMessageResponse( id="msg_005", @@ -56,31 +56,31 @@ message="Soy estudiante de ingeniería y tu portfolio me ha inspirado mucho. ¿Podrías darme algunos consejos sobre cómo mejorar mis habilidades en desarrollo backend?", sent_at=datetime(2025, 1, 14, 11, 0, 0), created_at=datetime(2025, 1, 14, 11, 0, 0), - updated_at=datetime(2025, 1, 14, 11, 0, 0) - ) + updated_at=datetime(2025, 1, 14, 11, 0, 0), + ), ] @router.get( "", - response_model=List[ContactMessageResponse], + response_model=list[ContactMessageResponse], summary="Listar mensajes de contacto (ADMIN)", - description="Obtiene todos los mensajes de contacto recibidos" + description="Obtiene todos los mensajes de contacto recibidos", ) async def get_contact_messages(): """ Lista todos los mensajes de contacto del perfil único del sistema. - + Los mensajes se retornan ordenados por sent_at descendente (más recientes primero). - + ⚠️ **ENDPOINT PRIVADO**: Requiere autenticación de administrador. - + Returns: List[ContactMessageResponse]: Lista de mensajes ordenados por fecha - + Relación: - Todos los mensajes pertenecen al Profile único del sistema - + TODO: Implementar con GetContactMessagesUseCase TODO: Requiere autenticación de admin (JWT) TODO: Ordenar por sent_at DESC (más recientes primero) @@ -93,33 +93,33 @@ async def get_contact_messages(): "/{message_id}", response_model=ContactMessageResponse, summary="Obtener mensaje de contacto (ADMIN)", - description="Obtiene un mensaje de contacto específico por ID" + description="Obtiene un mensaje de contacto específico por ID", ) async def get_contact_message(message_id: str): """ Obtiene un mensaje de contacto por su ID. - + ⚠️ **ENDPOINT PRIVADO**: Requiere autenticación de administrador. - + Args: message_id: ID único del mensaje - + Returns: ContactMessageResponse: Mensaje encontrado - + Raises: HTTPException 404: Si el mensaje no existe - + TODO: Implementar con GetContactMessageUseCase TODO: Requiere autenticación de admin """ for msg in MOCK_MESSAGES: if msg.id == message_id: return msg - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Mensaje con ID '{message_id}' no encontrado" + detail=f"Mensaje con ID '{message_id}' no encontrado", ) @@ -128,42 +128,39 @@ async def get_contact_message(message_id: str): response_model=MessageResponse, status_code=status.HTTP_201_CREATED, summary="Enviar mensaje de contacto (PÚBLICO)", - description="Crea un nuevo mensaje desde el formulario de contacto público" + description="Crea un nuevo mensaje desde el formulario de contacto público", ) -async def create_contact_message( - message_data: ContactMessageCreate, - request: Request -): +async def create_contact_message(_message_data: ContactMessageCreate, _request: Request): """ Crea un nuevo mensaje de contacto desde el formulario público del portfolio. - + ✅ **ENDPOINT PÚBLICO**: No requiere autenticación. - + Este es el endpoint que el formulario de contacto del portfolio llama cuando un visitante envía un mensaje. - + **Invariantes validados automáticamente:** - `name` no puede estar vacío (min_length=1) - `email` no puede estar vacío y debe ser válido (EmailStr) - `message` no puede estar vacío (min_length=1) - `sent_at` se genera automáticamente en el servidor - + Args: message_data: Datos del mensaje (name, email, message) request: Request de FastAPI para obtener IP - + Returns: MessageResponse: Confirmación de envío - + Raises: HTTPException 422: Si los datos no cumplen las invariantes HTTPException 429: Si se excede el rate limit (anti-spam) - + Seguridad: - Este endpoint debe tener rate limiting para prevenir spam - Considerar implementar CAPTCHA (reCAPTCHA, hCaptcha) - Registrar IP del remitente para análisis de spam - + TODO: Implementar con CreateContactMessageUseCase TODO: Añadir rate limiting (ej: máximo 3 mensajes por IP por hora) TODO: Considerar integración con CAPTCHA @@ -171,25 +168,23 @@ async def create_contact_message( TODO: Registrar IP del cliente para seguridad """ # Capturar IP para seguridad/anti-spam - client_ip = request.client.host - + # TODO: Validar rate limit # if rate_limiter.is_exceeded(client_ip): # raise HTTPException( # status_code=status.HTTP_429_TOO_MANY_REQUESTS, # detail="Has enviado demasiados mensajes. Por favor, espera un momento." # ) - + # TODO: Validar CAPTCHA si está implementado # if not verify_captcha(captcha_token): # raise HTTPException( # status_code=status.HTTP_400_BAD_REQUEST, # detail="Validación de CAPTCHA fallida" # ) - + return MessageResponse( - success=True, - message="¡Mensaje enviado correctamente! Te responderemos pronto." + success=True, message="¡Mensaje enviado correctamente! Te responderemos pronto." ) @@ -197,32 +192,31 @@ async def create_contact_message( "/{message_id}", response_model=MessageResponse, summary="Eliminar mensaje de contacto (ADMIN)", - description="Elimina un mensaje de contacto" + description="Elimina un mensaje de contacto", ) async def delete_contact_message(message_id: str): """ Elimina un mensaje de contacto. - + ⚠️ **ENDPOINT PRIVADO**: Requiere autenticación de administrador. - + Útil para limpiar spam o mensajes irrelevantes. - + Args: message_id: ID del mensaje a eliminar - + Returns: MessageResponse: Confirmación de eliminación - + Raises: HTTPException 404: Si el mensaje no existe - + TODO: Implementar con DeleteContactMessageUseCase TODO: Requiere autenticación de admin TODO: Considerar soft delete en vez de hard delete (para auditoría) """ return MessageResponse( - success=True, - message=f"Mensaje '{message_id}' eliminado correctamente" + success=True, message=f"Mensaje '{message_id}' eliminado correctamente" ) @@ -230,16 +224,16 @@ async def delete_contact_message(message_id: str): "/stats/summary", response_model=dict, summary="Estadísticas de mensajes (ADMIN)", - description="Obtiene estadísticas sobre los mensajes recibidos" + description="Obtiene estadísticas sobre los mensajes recibidos", ) async def get_contact_messages_stats(): """ Calcula estadísticas sobre los mensajes de contacto. - + ⚠️ **ENDPOINT PRIVADO**: Requiere autenticación de administrador. - + Útil para dashboard del admin panel. - + Returns: dict: Estadísticas Ejemplo: @@ -253,58 +247,62 @@ async def get_contact_messages_stats(): "2025-01-19": 5 } } - + TODO: Implementar con GetContactMessagesStatsUseCase TODO: Requiere autenticación de admin TODO: Calcular métricas de tiempo real """ from datetime import date, timedelta - + today = date.today() - - stats = { + + stats: dict[str, Any] = { "total": len(MOCK_MESSAGES), "today": len([m for m in MOCK_MESSAGES if m.sent_at.date() == today]), - "this_week": len([m for m in MOCK_MESSAGES if m.sent_at.date() >= today - timedelta(days=7)]), - "this_month": len([m for m in MOCK_MESSAGES if m.sent_at.date() >= today - timedelta(days=30)]), - "by_day": {} + "this_week": len( + [m for m in MOCK_MESSAGES if m.sent_at.date() >= today - timedelta(days=7)] + ), + "this_month": len( + [m for m in MOCK_MESSAGES if m.sent_at.date() >= today - timedelta(days=30)] + ), + "by_day": {}, } - + # Contar por día (últimos 7 días) for i in range(7): day = today - timedelta(days=i) count = len([m for m in MOCK_MESSAGES if m.sent_at.date() == day]) stats["by_day"][str(day)] = count - + return stats @router.get( "/recent/{limit}", - response_model=List[ContactMessageResponse], + response_model=list[ContactMessageResponse], summary="Mensajes recientes (ADMIN)", - description="Obtiene los N mensajes más recientes" + description="Obtiene los N mensajes más recientes", ) async def get_recent_contact_messages(limit: int = 10): """ Obtiene los mensajes más recientes. - + ⚠️ **ENDPOINT PRIVADO**: Requiere autenticación de administrador. - + Útil para mostrar en el dashboard del admin panel. - + Args: limit: Número de mensajes a retornar (default: 10, max: 50) - + Returns: List[ContactMessageResponse]: Mensajes más recientes - + TODO: Implementar con GetRecentContactMessagesUseCase TODO: Requiere autenticación de admin TODO: Limitar máximo a 50 mensajes """ if limit > 50: limit = 50 - + sorted_messages = sorted(MOCK_MESSAGES, key=lambda x: x.sent_at, reverse=True) - return sorted_messages[:limit] \ No newline at end of file + return sorted_messages[:limit] diff --git a/app/api/v1/routers/cv_router.py b/app/api/v1/routers/cv_router.py index 98a9dcf..7db3f60 100644 --- a/app/api/v1/routers/cv_router.py +++ b/app/api/v1/routers/cv_router.py @@ -1,18 +1,19 @@ +from datetime import date, datetime + from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse -from datetime import datetime, date +from app.api.schemas.additional_training_schema import AdditionalTrainingResponse +from app.api.schemas.certification_schema import CertificationResponse +from app.api.schemas.contact_info_schema import ContactInformationResponse from app.api.schemas.cv_schema import CVCompleteResponse +from app.api.schemas.education_schema import EducationResponse from app.api.schemas.profile_schema import ProfileResponse -from app.api.schemas.contact_info_schema import ContactInformationResponse -from app.api.schemas.social_networks_schema import SocialNetworkResponse from app.api.schemas.projects_schema import ProjectResponse -from app.api.schemas.work_experience_schema import WorkExperienceResponse from app.api.schemas.skill_schema import SkillResponse +from app.api.schemas.social_networks_schema import SocialNetworkResponse from app.api.schemas.tools_schema import ToolResponse -from app.api.schemas.education_schema import EducationResponse -from app.api.schemas.additional_training_schema import AdditionalTrainingResponse -from app.api.schemas.certification_schema import CertificationResponse +from app.api.schemas.work_experience_schema import WorkExperienceResponse router = APIRouter(prefix="/cv", tags=["CV"]) @@ -27,7 +28,7 @@ profile_image="https://example.com/images/profile.jpg", banner_image="https://example.com/images/banner.jpg", created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), contact_info=ContactInformationResponse( id="contact_001", @@ -36,7 +37,7 @@ location="Valencia, España", website="https://juanperez.dev", created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), social_networks=[ SocialNetworkResponse( @@ -46,7 +47,7 @@ icon="fab fa-github", order_index=0, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SocialNetworkResponse( id="social_002", @@ -55,8 +56,8 @@ icon="fab fa-linkedin", order_index=1, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ], work_experiences=[ WorkExperienceResponse( @@ -70,7 +71,7 @@ technologies=["Python", "FastAPI", "React", "MongoDB", "Docker"], order_index=0, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), WorkExperienceResponse( id="exp_002", @@ -83,8 +84,8 @@ technologies=["Node.js", "Vue.js", "PostgreSQL", "AWS"], order_index=1, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ], projects=[ ProjectResponse( @@ -97,20 +98,27 @@ images=["https://example.com/images/portfolio.jpg"], order_index=0, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), ProjectResponse( id="proj_002", title="E-commerce API REST", description="API REST completa para e-commerce con sistema de autenticación JWT, gestión de productos, carrito de compras y procesamiento de pagos con Stripe.", - technologies=["Python", "FastAPI", "PostgreSQL", "Stripe", "Redis", "Docker"], + technologies=[ + "Python", + "FastAPI", + "PostgreSQL", + "Stripe", + "Redis", + "Docker", + ], repository_url="https://github.com/juanperez/ecommerce-api", live_demo_url=None, images=[], order_index=1, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ], skills=[ SkillResponse( @@ -120,7 +128,7 @@ category="backend", order_index=0, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_002", @@ -129,7 +137,7 @@ category="backend", order_index=1, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_003", @@ -138,7 +146,7 @@ category="frontend", order_index=2, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_004", @@ -147,7 +155,7 @@ category="database", order_index=3, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_005", @@ -156,8 +164,8 @@ category="database", order_index=4, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ], tools=[ ToolResponse( @@ -167,7 +175,7 @@ knowledge_level="expert", order_index=0, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), ToolResponse( id="tool_002", @@ -176,7 +184,7 @@ knowledge_level="advanced", order_index=1, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), ToolResponse( id="tool_003", @@ -185,7 +193,7 @@ knowledge_level="expert", order_index=2, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), ToolResponse( id="tool_004", @@ -194,8 +202,8 @@ knowledge_level="advanced", order_index=3, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ], education=[ EducationResponse( @@ -207,7 +215,7 @@ description="Especialización en Ingeniería del Software y Arquitecturas de Software. Nota media: 8.5/10", order_index=0, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ) ], additional_training=[ @@ -222,7 +230,7 @@ technologies=["Python", "FastAPI", "Design Patterns", "SOLID", "DDD"], order_index=0, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), AdditionalTrainingResponse( id="train_002", @@ -235,8 +243,8 @@ technologies=["React", "TypeScript", "Performance"], order_index=1, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ], certifications=[ CertificationResponse( @@ -249,9 +257,9 @@ credential_url="https://www.credly.com/badges/aws-saa-123456789", order_index=0, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ) - ] + ], ) @@ -259,35 +267,35 @@ "", response_model=CVCompleteResponse, summary="Obtener CV completo", - description="Obtiene TODA la información del CV para mostrar en el portfolio" + description="Obtiene TODA la información del CV para mostrar en el portfolio", ) async def get_complete_cv(): """ Retorna el CV completo con TODAS las secciones del portfolio. - + Este endpoint combina información de todas las entidades relacionadas con Profile: - + **Información Personal:** - Profile (único en el sistema) - ContactInformation (1-a-1 con Profile) - SocialNetworks (muchos) - + **Experiencia Profesional:** - WorkExperiences (muchos) - Projects (muchos) - + **Habilidades:** - Skills / TechnicalSkills (muchos) - Tools (muchos) - + **Formación:** - Education (muchos) - AdditionalTraining (muchos) - Certifications (muchos) - + Returns: CVCompleteResponse: Objeto con toda la información del portfolio - + TODO: Implementar con GetCompleteCVUseCase """ return MOCK_CV_COMPLETE @@ -297,21 +305,21 @@ async def get_complete_cv(): "/download", summary="Descargar CV en PDF", description="Genera y descarga el CV en formato PDF profesional", - response_class=FileResponse + response_class=FileResponse, ) async def download_cv_pdf(): """ Genera un PDF del CV completo y lo retorna para descarga. - + Returns: FileResponse: Archivo PDF descargable - + Raises: HTTPException 501: Mientras no esté implementado - + TODO: Implementar con GenerateCVPDFUseCase """ raise HTTPException( status_code=501, - detail="Funcionalidad de descarga PDF aún no implementada. Próximamente disponible." - ) \ No newline at end of file + detail="Funcionalidad de descarga PDF aún no implementada. Próximamente disponible.", + ) diff --git a/app/api/v1/routers/education_router.py b/app/api/v1/routers/education_router.py index 4d8b807..e7c648a 100644 --- a/app/api/v1/routers/education_router.py +++ b/app/api/v1/routers/education_router.py @@ -1,13 +1,13 @@ +from datetime import date, datetime + from fastapi import APIRouter, HTTPException, status -from typing import List -from datetime import datetime, date +from app.api.schemas.common_schema import MessageResponse from app.api.schemas.education_schema import ( - EducationResponse, EducationCreate, - EducationUpdate + EducationResponse, + EducationUpdate, ) -from app.api.schemas.common_schema import MessageResponse router = APIRouter(prefix="/education", tags=["Education"]) @@ -22,7 +22,7 @@ description="Especialización en Ingeniería del Software. Proyecto final: Sistema de gestión hospitalaria con arquitectura microservicios. Nota media: 8.5/10", order_index=1, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), EducationResponse( id="edu_002", @@ -33,7 +33,7 @@ description="Formación práctica en desarrollo web. Tecnologías: HTML, CSS, JavaScript, PHP, MySQL", order_index=2, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), EducationResponse( id="edu_003", @@ -44,30 +44,30 @@ description="Cursando actualmente. Enfoque en arquitecturas de software, microservicios y DevOps", order_index=0, # Orden 0 para mostrar primero (en curso) created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ] @router.get( "", - response_model=List[EducationResponse], + response_model=list[EducationResponse], summary="Listar formación académica", - description="Obtiene toda la formación académica ordenada por orderIndex" + description="Obtiene toda la formación académica ordenada por orderIndex", ) async def get_education(): """ Lista toda la formación académica del perfil único del sistema. - + La formación se retorna ordenada por `order_index` ascendente. Típicamente, la formación más reciente o en curso tiene order_index menor. - + Returns: List[EducationResponse]: Lista de formación académica ordenada - + Relación: - Toda la formación pertenece al Profile único del sistema - + TODO: Implementar con GetEducationListUseCase TODO: Ordenar por order_index ASC (en curso primero, luego más reciente) """ @@ -78,30 +78,30 @@ async def get_education(): "/{education_id}", response_model=EducationResponse, summary="Obtener formación académica", - description="Obtiene una formación académica específica por ID" + description="Obtiene una formación académica específica por ID", ) async def get_education_by_id(education_id: str): """ Obtiene una formación académica por su ID. - + Args: education_id: ID único de la formación - + Returns: EducationResponse: Formación encontrada - + Raises: HTTPException 404: Si la formación no existe - + TODO: Implementar con GetEducationUseCase """ for edu in MOCK_EDUCATION: if edu.id == education_id: return edu - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Formación académica con ID '{education_id}' no encontrada" + detail=f"Formación académica con ID '{education_id}' no encontrada", ) @@ -110,28 +110,28 @@ async def get_education_by_id(education_id: str): response_model=EducationResponse, status_code=status.HTTP_201_CREATED, summary="Crear formación académica", - description="Crea una nueva formación académica asociada al perfil" + description="Crea una nueva formación académica asociada al perfil", ) -async def create_education(education_data: EducationCreate): +async def create_education(_education_data: EducationCreate): """ Crea una nueva formación académica y la asocia al perfil único del sistema. - + **Invariantes que se validan automáticamente:** - `institution` no puede estar vacía (min_length=1) - `degree` no puede estar vacío (min_length=1) - `startDate` es obligatoria - Si `endDate` existe, debe ser posterior a `startDate` (validador Pydantic) - + Args: education_data: Datos de la formación a crear - + Returns: EducationResponse: Formación creada - + Raises: HTTPException 422: Si endDate <= startDate HTTPException 400: Si los datos no cumplen las invariantes - + TODO: Implementar con CreateEducationUseCase TODO: Requiere autenticación de admin """ @@ -142,32 +142,29 @@ async def create_education(education_data: EducationCreate): "/{education_id}", response_model=EducationResponse, summary="Actualizar formación académica", - description="Actualiza una formación académica existente" + description="Actualiza una formación académica existente", ) -async def update_education( - education_id: str, - education_data: EducationUpdate -): +async def update_education(education_id: str, _education_data: EducationUpdate): """ Actualiza una formación académica existente. - + **Invariantes:** - Si se actualiza `institution`, no puede estar vacía - Si se actualiza `degree`, no puede estar vacío - Si se actualiza `endDate`, debe ser posterior a `startDate` - + Args: education_id: ID de la formación a actualizar education_data: Datos a actualizar (campos opcionales) - + Returns: EducationResponse: Formación actualizada - + Raises: HTTPException 404: Si la formación no existe HTTPException 422: Si endDate <= startDate HTTPException 400: Si los datos no cumplen las invariantes - + TODO: Implementar con UpdateEducationUseCase TODO: Validar fechas si se actualizan ambas o una de ellas TODO: Requiere autenticación de admin @@ -175,10 +172,10 @@ async def update_education( for edu in MOCK_EDUCATION: if edu.id == education_id: return edu - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Formación académica con ID '{education_id}' no encontrada" + detail=f"Formación académica con ID '{education_id}' no encontrada", ) @@ -186,46 +183,46 @@ async def update_education( "/{education_id}", response_model=MessageResponse, summary="Eliminar formación académica", - description="Elimina una formación académica del perfil" + description="Elimina una formación académica del perfil", ) async def delete_education(education_id: str): """ Elimina una formación académica del perfil. - + Nota: Al eliminar una formación, puede ser necesario reordenar los orderIndex de la formación restante para mantener la coherencia. - + Args: education_id: ID de la formación a eliminar - + Returns: MessageResponse: Confirmación de eliminación - + Raises: HTTPException 404: Si la formación no existe - + TODO: Implementar con DeleteEducationUseCase TODO: Considerar reordenamiento automático de orderIndex TODO: Requiere autenticación de admin """ return MessageResponse( success=True, - message=f"Formación académica '{education_id}' eliminada correctamente" + message=f"Formación académica '{education_id}' eliminada correctamente", ) @router.patch( "/reorder", - response_model=List[EducationResponse], + response_model=list[EducationResponse], summary="Reordenar formación académica", - description="Actualiza el orderIndex de múltiples formaciones de una vez" + description="Actualiza el orderIndex de múltiples formaciones de una vez", ) -async def reorder_education(education_orders: List[dict]): +async def reorder_education(_education_orders: list[dict]): """ Reordena múltiples formaciones académicas de una sola vez. - + Útil para drag & drop en el panel de administración. - + Args: education_orders: Lista de objetos con {id, orderIndex} Ejemplo: [ @@ -233,18 +230,18 @@ async def reorder_education(education_orders: List[dict]): {"id": "edu_002", "orderIndex": 1}, {"id": "edu_003", "orderIndex": 0} ] - + Returns: List[EducationResponse]: Formaciones reordenadas - + Raises: HTTPException 400: Si hay orderIndex duplicados HTTPException 404: Si algún education_id no existe - + TODO: Implementar con ReorderEducationUseCase TODO: Validar que todos los orderIndex sean únicos TODO: Validar que todos los education_id existan TODO: Hacer update en transacción (todo o nada) TODO: Requiere autenticación de admin """ - return sorted(MOCK_EDUCATION, key=lambda x: x.order_index) \ No newline at end of file + return sorted(MOCK_EDUCATION, key=lambda x: x.order_index) diff --git a/app/api/v1/routers/health_router.py b/app/api/v1/routers/health_router.py index 05852b5..bb7220a 100644 --- a/app/api/v1/routers/health_router.py +++ b/app/api/v1/routers/health_router.py @@ -1,8 +1,10 @@ +from datetime import datetime + from fastapi import APIRouter, Depends from motor.motor_asyncio import AsyncIOMotorDatabase -from app.infrastructure.database.mongo_client import get_database + from app.config.settings import settings -from datetime import datetime +from app.infrastructure.database.mongo_client import get_database router = APIRouter(tags=["Health"]) @@ -15,7 +17,7 @@ async def health_check(): "service": settings.PROJECT_NAME, "version": settings.VERSION, "environment": settings.ENVIRONMENT, - "timestamp": datetime.utcnow().isoformat() + "timestamp": datetime.utcnow().isoformat(), } @@ -24,16 +26,16 @@ async def health_check_db(db: AsyncIOMotorDatabase = Depends(get_database)): """Health check con verificación de base de datos""" try: # Ping a MongoDB - await db.client.admin.command('ping') + await db.client.admin.command("ping") db_status = "connected" except Exception as e: db_status = f"error: {str(e)}" - + return { "status": "ok" if db_status == "connected" else "error", "service": settings.PROJECT_NAME, "version": settings.VERSION, "environment": settings.ENVIRONMENT, "database": db_status, - "timestamp": datetime.utcnow().isoformat() - } \ No newline at end of file + "timestamp": datetime.utcnow().isoformat(), + } diff --git a/app/api/v1/routers/profile_router.py b/app/api/v1/routers/profile_router.py index 34349e3..9a0b135 100644 --- a/app/api/v1/routers/profile_router.py +++ b/app/api/v1/routers/profile_router.py @@ -1,12 +1,9 @@ -from fastapi import APIRouter, HTTPException, status from datetime import datetime -from app.api.schemas.profile_schema import ( - ProfileResponse, - ProfileCreate, - ProfileUpdate -) +from fastapi import APIRouter, status + from app.api.schemas.common_schema import MessageResponse +from app.api.schemas.profile_schema import ProfileCreate, ProfileResponse, ProfileUpdate router = APIRouter(prefix="/profile", tags=["Profile"]) @@ -20,7 +17,7 @@ profile_image="https://example.com/images/profile.jpg", banner_image="https://example.com/images/banner.jpg", created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ) @@ -28,17 +25,17 @@ "", response_model=ProfileResponse, summary="Obtener perfil", - description="Obtiene el perfil único del sistema" + description="Obtiene el perfil único del sistema", ) async def get_profile(): """ Obtiene el perfil profesional. - + **Invariante**: Solo existe UN perfil en el sistema. - + Returns: ProfileResponse: El perfil único del usuario - + TODO: Implementar con GetProfileUseCase """ return MOCK_PROFILE @@ -48,22 +45,22 @@ async def get_profile(): "", response_model=ProfileResponse, summary="Actualizar perfil", - description="Actualiza la información del perfil único" + description="Actualiza la información del perfil único", ) -async def update_profile(profile_data: ProfileUpdate): +async def update_profile(_profile_data: ProfileUpdate): """ Actualiza el perfil profesional. - + **Invariantes**: - `full_name` no puede estar vacío - `headline` no puede estar vacío - + Args: profile_data: Datos a actualizar (campos opcionales) - + Returns: ProfileResponse: Perfil actualizado - + TODO: Implementar con UpdateProfileUseCase TODO: Validar que full_name y headline no queden vacíos """ @@ -75,26 +72,26 @@ async def update_profile(profile_data: ProfileUpdate): response_model=ProfileResponse, status_code=status.HTTP_201_CREATED, summary="Crear perfil inicial", - description="Crea el perfil único del sistema (solo si no existe)" + description="Crea el perfil único del sistema (solo si no existe)", ) -async def create_profile(profile_data: ProfileCreate): +async def create_profile(_profile_data: ProfileCreate): """ Crea el perfil profesional inicial. - + **Invariante crítico**: Solo puede existir UN perfil en el sistema. - + Este endpoint solo debe ejecutarse UNA VEZ en la vida del sistema, durante la configuración inicial. - + Args: profile_data: Datos del perfil a crear - + Returns: ProfileResponse: Perfil creado - + Raises: HTTPException 409: Si ya existe un perfil en el sistema - + TODO: Implementar con CreateProfileUseCase TODO: Validar que NO exista ya un perfil antes de crear """ @@ -103,7 +100,7 @@ async def create_profile(profile_data: ProfileCreate): # status_code=status.HTTP_409_CONFLICT, # detail="Ya existe un perfil en el sistema. Solo puede haber uno." # ) - + return MOCK_PROFILE @@ -111,14 +108,14 @@ async def create_profile(profile_data: ProfileCreate): "", response_model=MessageResponse, summary="Eliminar perfil (PELIGROSO)", - description="Elimina el perfil único del sistema" + description="Elimina el perfil único del sistema", ) async def delete_profile(): """ Elimina el perfil del sistema. - + ⚠️ **ENDPOINT PELIGROSO**: Esto eliminará TODA la información del perfil. - + Dado que el perfil tiene relaciones con: - Projects - Education @@ -129,15 +126,14 @@ async def delete_profile(): - ContactInformation - ContactMessage - SocialNetwork - + Se debe decidir la estrategia de cascada (eliminar todo o fallar). - + TODO: Implementar con DeleteProfileUseCase TODO: Decidir estrategia de eliminación en cascada TODO: Requiere autenticación de admin TODO: Considerar soft delete en vez de hard delete """ return MessageResponse( - success=True, - message="Perfil eliminado correctamente (y todas sus relaciones)" - ) \ No newline at end of file + success=True, message="Perfil eliminado correctamente (y todas sus relaciones)" + ) diff --git a/app/api/v1/routers/projects_router.py b/app/api/v1/routers/projects_router.py index 11464e8..6ea6fb7 100644 --- a/app/api/v1/routers/projects_router.py +++ b/app/api/v1/routers/projects_router.py @@ -1,13 +1,13 @@ -from fastapi import APIRouter, HTTPException, status -from typing import List from datetime import datetime +from fastapi import APIRouter, HTTPException, status + +from app.api.schemas.common_schema import MessageResponse from app.api.schemas.projects_schema import ( - ProjectResponse, ProjectCreate, - ProjectUpdate + ProjectResponse, + ProjectUpdate, ) -from app.api.schemas.common_schema import MessageResponse router = APIRouter(prefix="/projects", tags=["Projects"]) @@ -17,70 +17,93 @@ id="proj_001", title="Portfolio Personal con Clean Architecture", description="Portfolio web profesional desarrollado con Astro en el frontend y FastAPI en el backend, siguiendo principios de Clean Architecture. Incluye sistema de gestión de contenido dinámico con MongoDB, generación automática de CV en PDF y panel de administración para actualizar información sin tocar código.", - technologies=["Astro", "FastAPI", "MongoDB", "Tailwind CSS", "Docker", "Python", "TypeScript"], + technologies=[ + "Astro", + "FastAPI", + "MongoDB", + "Tailwind CSS", + "Docker", + "Python", + "TypeScript", + ], repository_url="https://github.com/juanperez/portfolio", live_demo_url="https://juanperez.dev", images=[ "https://example.com/images/portfolio-home.jpg", "https://example.com/images/portfolio-cv.jpg", - "https://example.com/images/portfolio-admin.jpg" + "https://example.com/images/portfolio-admin.jpg", ], order_index=1, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), ProjectResponse( id="proj_002", title="E-commerce API REST", description="API REST completa para e-commerce con sistema de autenticación JWT, gestión de productos, inventario, carrito de compras y procesamiento de pagos con Stripe. Incluye sistema de notificaciones por email y webhooks para sincronización con sistemas externos.", - technologies=["Python", "FastAPI", "PostgreSQL", "Stripe", "Redis", "Docker", "Celery"], + technologies=[ + "Python", + "FastAPI", + "PostgreSQL", + "Stripe", + "Redis", + "Docker", + "Celery", + ], repository_url="https://github.com/juanperez/ecommerce-api", live_demo_url="https://ecommerce-demo.juanperez.dev", images=[ "https://example.com/images/ecommerce-api.jpg", - "https://example.com/images/ecommerce-docs.jpg" + "https://example.com/images/ecommerce-docs.jpg", ], order_index=2, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), ProjectResponse( id="proj_003", title="Task Management Dashboard", description="Dashboard de gestión de tareas tipo Trello con drag & drop, colaboración en tiempo real usando WebSockets, notificaciones push y sistema de roles y permisos.", - technologies=["React", "Node.js", "Socket.io", "MongoDB", "Redux", "Material-UI"], + technologies=[ + "React", + "Node.js", + "Socket.io", + "MongoDB", + "Redux", + "Material-UI", + ], repository_url="https://github.com/juanperez/task-dashboard", live_demo_url="https://tasks.juanperez.dev", images=[ "https://example.com/images/tasks-board.jpg", - "https://example.com/images/tasks-kanban.jpg" + "https://example.com/images/tasks-kanban.jpg", ], order_index=3, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ] @router.get( "", - response_model=List[ProjectResponse], + response_model=list[ProjectResponse], summary="Listar proyectos", - description="Obtiene todos los proyectos del perfil ordenados por orderIndex" + description="Obtiene todos los proyectos del perfil ordenados por orderIndex", ) async def get_projects(): """ Lista todos los proyectos del perfil único del sistema. - + Los proyectos se retornan ordenados por `order_index` ascendente, mostrando primero los proyectos con menor índice (más importantes). - + Returns: List[ProjectResponse]: Lista de proyectos ordenados - + Relación: - Todos los proyectos pertenecen al Profile único del sistema - + TODO: Implementar con GetProjectsUseCase TODO: Ordenar por order_index ASC """ @@ -91,30 +114,30 @@ async def get_projects(): "/{project_id}", response_model=ProjectResponse, summary="Obtener proyecto", - description="Obtiene un proyecto específico por ID" + description="Obtiene un proyecto específico por ID", ) async def get_project(project_id: str): """ Obtiene un proyecto por su ID. - + Args: project_id: ID único del proyecto - + Returns: ProjectResponse: Proyecto encontrado - + Raises: HTTPException 404: Si el proyecto no existe - + TODO: Implementar con GetProjectUseCase """ for proj in MOCK_PROJECTS: if proj.id == project_id: return proj - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Proyecto con ID '{project_id}' no encontrado" + detail=f"Proyecto con ID '{project_id}' no encontrado", ) @@ -123,27 +146,27 @@ async def get_project(project_id: str): response_model=ProjectResponse, status_code=status.HTTP_201_CREATED, summary="Crear proyecto", - description="Crea un nuevo proyecto asociado al perfil" + description="Crea un nuevo proyecto asociado al perfil", ) -async def create_project(project_data: ProjectCreate): +async def create_project(_project_data: ProjectCreate): """ Crea un nuevo proyecto y lo asocia al perfil único del sistema. - + **Invariantes que se deben validar:** - `title` no puede estar vacío - `description` no puede estar vacía - `orderIndex` debe ser único dentro del perfil - + Args: project_data: Datos del proyecto a crear - + Returns: ProjectResponse: Proyecto creado - + Raises: HTTPException 409: Si ya existe un proyecto con el mismo orderIndex HTTPException 400: Si los datos no cumplen las invariantes - + TODO: Implementar con CreateProjectUseCase TODO: Validar que orderIndex sea único TODO: Considerar auto-incrementar orderIndex si no se proporciona @@ -155,7 +178,7 @@ async def create_project(project_data: ProjectCreate): # status_code=status.HTTP_409_CONFLICT, # detail=f"Ya existe un proyecto con orderIndex {project_data.order_index}" # ) - + return MOCK_PROJECTS[0] @@ -163,32 +186,29 @@ async def create_project(project_data: ProjectCreate): "/{project_id}", response_model=ProjectResponse, summary="Actualizar proyecto", - description="Actualiza un proyecto existente" + description="Actualiza un proyecto existente", ) -async def update_project( - project_id: str, - project_data: ProjectUpdate -): +async def update_project(project_id: str, _project_data: ProjectUpdate): """ Actualiza un proyecto existente. - + **Invariantes:** - Si se actualiza `title`, no puede estar vacío - Si se actualiza `description`, no puede estar vacía - Si se actualiza `orderIndex`, debe ser único dentro del perfil - + Args: project_id: ID del proyecto a actualizar project_data: Datos a actualizar (campos opcionales) - + Returns: ProjectResponse: Proyecto actualizado - + Raises: HTTPException 404: Si el proyecto no existe HTTPException 409: Si el nuevo orderIndex ya está en uso HTTPException 400: Si los datos no cumplen las invariantes - + TODO: Implementar con UpdateProjectUseCase TODO: Validar que orderIndex sea único si se actualiza TODO: Requiere autenticación de admin @@ -197,17 +217,17 @@ async def update_project( if proj.id == project_id: # Mock: Validar orderIndex único si se actualiza # if project_data.order_index is not None: - # if any(p.order_index == project_data.order_index and p.id != project_id + # if any(p.order_index == project_data.order_index and p.id != project_id # for p in MOCK_PROJECTS): # raise HTTPException( # status_code=status.HTTP_409_CONFLICT, # detail=f"Ya existe otro proyecto con orderIndex {project_data.order_index}" # ) return proj - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Proyecto con ID '{project_id}' no encontrado" + detail=f"Proyecto con ID '{project_id}' no encontrado", ) @@ -215,46 +235,45 @@ async def update_project( "/{project_id}", response_model=MessageResponse, summary="Eliminar proyecto", - description="Elimina un proyecto del perfil" + description="Elimina un proyecto del perfil", ) async def delete_project(project_id: str): """ Elimina un proyecto del perfil. - + Nota: Al eliminar un proyecto, puede ser necesario reordenar los orderIndex de los proyectos restantes para evitar huecos. - + Args: project_id: ID del proyecto a eliminar - + Returns: MessageResponse: Confirmación de eliminación - + Raises: HTTPException 404: Si el proyecto no existe - + TODO: Implementar con DeleteProjectUseCase TODO: Considerar reordenamiento automático de orderIndex TODO: Requiere autenticación de admin """ return MessageResponse( - success=True, - message=f"Proyecto '{project_id}' eliminado correctamente" + success=True, message=f"Proyecto '{project_id}' eliminado correctamente" ) @router.patch( "/reorder", - response_model=List[ProjectResponse], + response_model=list[ProjectResponse], summary="Reordenar proyectos", - description="Actualiza el orderIndex de múltiples proyectos de una vez" + description="Actualiza el orderIndex de múltiples proyectos de una vez", ) -async def reorder_projects(project_orders: List[dict]): +async def reorder_projects(_project_orders: list[dict]): """ Reordena múltiples proyectos de una sola vez. - + Útil para drag & drop en el panel de administración. - + Args: project_orders: Lista de objetos con {id, orderIndex} Ejemplo: [ @@ -262,18 +281,18 @@ async def reorder_projects(project_orders: List[dict]): {"id": "proj_002", "orderIndex": 1}, {"id": "proj_003", "orderIndex": 3} ] - + Returns: List[ProjectResponse]: Proyectos reordenados - + Raises: HTTPException 400: Si hay orderIndex duplicados HTTPException 404: Si algún project_id no existe - + TODO: Implementar con ReorderProjectsUseCase TODO: Validar que todos los orderIndex sean únicos TODO: Validar que todos los project_id existan TODO: Hacer update en transacción (todo o nada) TODO: Requiere autenticación de admin """ - return sorted(MOCK_PROJECTS, key=lambda x: x.order_index) \ No newline at end of file + return sorted(MOCK_PROJECTS, key=lambda x: x.order_index) diff --git a/app/api/v1/routers/skill_router.py b/app/api/v1/routers/skill_router.py index 11d2088..8f092ea 100644 --- a/app/api/v1/routers/skill_router.py +++ b/app/api/v1/routers/skill_router.py @@ -1,15 +1,16 @@ -from fastapi import APIRouter, HTTPException, status -from typing import List from datetime import datetime +from typing import Any + +from fastapi import APIRouter, HTTPException, status +from app.api.schemas.common_schema import MessageResponse from app.api.schemas.skill_schema import ( - SkillResponse, + SkillCategory, SkillCreate, - SkillUpdate, SkillLevel, - SkillCategory + SkillResponse, + SkillUpdate, ) -from app.api.schemas.common_schema import MessageResponse router = APIRouter(prefix="/skills", tags=["Skills"]) @@ -23,7 +24,7 @@ category="backend", order_index=0, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_002", @@ -32,7 +33,7 @@ category="backend", order_index=1, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_003", @@ -41,7 +42,7 @@ category="backend", order_index=2, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), # Frontend SkillResponse( @@ -51,7 +52,7 @@ category="frontend", order_index=3, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_005", @@ -60,7 +61,7 @@ category="frontend", order_index=4, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_006", @@ -69,7 +70,7 @@ category="frontend", order_index=5, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), # Database SkillResponse( @@ -79,7 +80,7 @@ category="database", order_index=6, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_008", @@ -88,7 +89,7 @@ category="database", order_index=7, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), # DevOps SkillResponse( @@ -98,7 +99,7 @@ category="devops", order_index=8, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), SkillResponse( id="skill_010", @@ -107,7 +108,7 @@ category="devops", order_index=9, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), # Cloud SkillResponse( @@ -117,7 +118,7 @@ category="cloud", order_index=10, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), # Testing SkillResponse( @@ -127,53 +128,52 @@ category="testing", order_index=11, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ] @router.get( "", - response_model=List[SkillResponse], + response_model=list[SkillResponse], summary="Listar habilidades técnicas", - description="Obtiene todas las habilidades técnicas del perfil" + description="Obtiene todas las habilidades técnicas del perfil", ) async def get_skills( - category: SkillCategory = None, - level: SkillLevel = None + category: SkillCategory | None = None, level: SkillLevel | None = None ): """ Lista todas las habilidades técnicas del perfil único del sistema. - + Puede filtrar por categoría y/o nivel. - + Args: category: Filtrar por categoría (backend, frontend, devops, etc.) level: Filtrar por nivel (beginner, intermediate, advanced, expert) - + Returns: List[SkillResponse]: Lista de habilidades ordenadas por order_index - + Relación: - Todas las habilidades pertenecen al Profile único del sistema - Se relacionan con technologies en WorkExperience, Projects, AdditionalTraining - + Ejemplos: - GET /skills?category=backend - GET /skills?level=expert - GET /skills?category=frontend&level=advanced - + TODO: Implementar con GetSkillsUseCase TODO: Ordenar por order_index ASC """ skills = MOCK_SKILLS - + if category: skills = [s for s in skills if s.category == category] - + if level: skills = [s for s in skills if s.level == level] - + return sorted(skills, key=lambda x: x.order_index) @@ -181,30 +181,30 @@ async def get_skills( "/{skill_id}", response_model=SkillResponse, summary="Obtener habilidad técnica", - description="Obtiene una habilidad técnica específica por ID" + description="Obtiene una habilidad técnica específica por ID", ) async def get_skill(skill_id: str): """ Obtiene una habilidad técnica por su ID. - + Args: skill_id: ID único de la habilidad - + Returns: SkillResponse: Habilidad encontrada - + Raises: HTTPException 404: Si la habilidad no existe - + TODO: Implementar con GetSkillUseCase """ for skill in MOCK_SKILLS: if skill.id == skill_id: return skill - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Habilidad con ID '{skill_id}' no encontrada" + detail=f"Habilidad con ID '{skill_id}' no encontrada", ) @@ -213,29 +213,29 @@ async def get_skill(skill_id: str): response_model=SkillResponse, status_code=status.HTTP_201_CREATED, summary="Crear habilidad técnica", - description="Crea una nueva habilidad técnica asociada al perfil" + description="Crea una nueva habilidad técnica asociada al perfil", ) -async def create_skill(skill_data: SkillCreate): +async def create_skill(_skill_data: SkillCreate): """ Crea una nueva habilidad técnica y la asocia al perfil único del sistema. - + **Invariantes que se validan automáticamente:** - `name` no puede estar vacío (min_length=1) - `level` debe ser: beginner, intermediate, advanced, expert - `category` debe ser un valor permitido - `orderIndex` debe ser único dentro del perfil - + Args: skill_data: Datos de la habilidad a crear - + Returns: SkillResponse: Habilidad creada - + Raises: HTTPException 422: Si level o category no son valores permitidos HTTPException 409: Si orderIndex ya está en uso HTTPException 400: Si los datos no cumplen las invariantes - + TODO: Implementar con CreateSkillUseCase TODO: Validar que orderIndex sea único dentro del perfil TODO: Considerar auto-incrementar orderIndex si no se proporciona @@ -247,7 +247,7 @@ async def create_skill(skill_data: SkillCreate): # status_code=status.HTTP_409_CONFLICT, # detail=f"Ya existe una habilidad con orderIndex {skill_data.order_index}" # ) - + return MOCK_SKILLS[0] @@ -255,37 +255,34 @@ async def create_skill(skill_data: SkillCreate): "/{skill_id}", response_model=SkillResponse, summary="Actualizar habilidad técnica", - description="Actualiza una habilidad técnica existente" + description="Actualiza una habilidad técnica existente", ) -async def update_skill( - skill_id: str, - skill_data: SkillUpdate -): +async def update_skill(skill_id: str, _skill_data: SkillUpdate): """ Actualiza una habilidad técnica existente. - + **Invariantes:** - Si se actualiza `name`, no puede estar vacío - Si se actualiza `level`, debe ser un valor permitido - Si se actualiza `category`, debe ser un valor permitido - Si se actualiza `orderIndex`, debe ser único dentro del perfil - + Args: skill_id: ID de la habilidad a actualizar skill_data: Datos a actualizar (campos opcionales) - + Returns: SkillResponse: Habilidad actualizada - + Raises: HTTPException 404: Si la habilidad no existe HTTPException 422: Si level o category no son válidos HTTPException 409: Si el nuevo orderIndex ya está en uso - + Caso de uso común: - Actualizar level cuando mejoras en una tecnología (ej: de "intermediate" a "advanced") - + TODO: Implementar con UpdateSkillUseCase TODO: Validar que orderIndex sea único si se actualiza TODO: Requiere autenticación de admin @@ -293,10 +290,10 @@ async def update_skill( for skill in MOCK_SKILLS: if skill.id == skill_id: return skill - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Habilidad con ID '{skill_id}' no encontrada" + detail=f"Habilidad con ID '{skill_id}' no encontrada", ) @@ -304,60 +301,59 @@ async def update_skill( "/{skill_id}", response_model=MessageResponse, summary="Eliminar habilidad técnica", - description="Elimina una habilidad técnica del perfil" + description="Elimina una habilidad técnica del perfil", ) async def delete_skill(skill_id: str): """ Elimina una habilidad técnica del perfil. - + Nota: Al eliminar una habilidad, puede ser necesario reordenar los orderIndex de las habilidades restantes para mantener la coherencia. - + Args: skill_id: ID de la habilidad a eliminar - + Returns: MessageResponse: Confirmación de eliminación - + Raises: HTTPException 404: Si la habilidad no existe - + TODO: Implementar con DeleteSkillUseCase TODO: Considerar reordenamiento automático de orderIndex TODO: Requiere autenticación de admin """ return MessageResponse( - success=True, - message=f"Habilidad '{skill_id}' eliminada correctamente" + success=True, message=f"Habilidad '{skill_id}' eliminada correctamente" ) @router.patch( "/reorder", - response_model=List[SkillResponse], + response_model=list[SkillResponse], summary="Reordenar habilidades técnicas", - description="Actualiza el orderIndex de múltiples habilidades de una vez" + description="Actualiza el orderIndex de múltiples habilidades de una vez", ) -async def reorder_skills(skill_orders: List[dict]): +async def reorder_skills(_skill_orders: list[dict]): """ Reordena múltiples habilidades técnicas de una sola vez. - + Útil para drag & drop en el panel de administración. - + Args: skill_orders: Lista de objetos con {id, orderIndex} Ejemplo: [ {"id": "skill_001", "orderIndex": 0}, {"id": "skill_002", "orderIndex": 1} ] - + Returns: List[SkillResponse]: Habilidades reordenadas - + Raises: HTTPException 400: Si hay orderIndex duplicados HTTPException 404: Si algún skill_id no existe - + TODO: Implementar con ReorderSkillsUseCase TODO: Validar que todos los orderIndex sean únicos TODO: Validar que todos los skill_id existan @@ -371,14 +367,14 @@ async def reorder_skills(skill_orders: List[dict]): "/grouped/by-category", response_model=dict, summary="Agrupar habilidades por categoría", - description="Obtiene habilidades agrupadas por categoría" + description="Obtiene habilidades agrupadas por categoría", ) async def get_skills_grouped_by_category(): """ Agrupa habilidades técnicas por categoría. - + Útil para mostrar skills organizadas en secciones en el portfolio. - + Returns: dict: Diccionario con categorías como keys y listas de skills como values Ejemplo: @@ -387,21 +383,21 @@ async def get_skills_grouped_by_category(): "frontend": [skill3, skill4], "devops": [skill5] } - + TODO: Implementar con GetSkillsGroupedByCategoryUseCase TODO: Ordenar skills dentro de cada categoría por order_index TODO: Considerar ordenar categorías por prioridad """ - grouped = {} + grouped: dict[str, list[SkillResponse]] = {} for skill in MOCK_SKILLS: if skill.category not in grouped: grouped[skill.category] = [] grouped[skill.category].append(skill) - + # Ordenar skills dentro de cada categoría por order_index for category in grouped: grouped[category] = sorted(grouped[category], key=lambda x: x.order_index) - + return grouped @@ -409,14 +405,14 @@ async def get_skills_grouped_by_category(): "/grouped/by-level", response_model=dict, summary="Agrupar habilidades por nivel", - description="Obtiene habilidades agrupadas por nivel de dominio" + description="Obtiene habilidades agrupadas por nivel de dominio", ) async def get_skills_grouped_by_level(): """ Agrupa habilidades técnicas por nivel de dominio. - + Útil para destacar expertise (mostrar primero las "expert"). - + Returns: dict: Diccionario con niveles como keys y listas de skills como values Ejemplo: @@ -425,20 +421,20 @@ async def get_skills_grouped_by_level(): "advanced": [skill3, skill4], "intermediate": [skill5] } - + TODO: Implementar con GetSkillsGroupedByLevelUseCase TODO: Ordenar skills dentro de cada nivel por order_index """ - grouped = {} + grouped: dict[str, list[SkillResponse]] = {} for skill in MOCK_SKILLS: if skill.level not in grouped: grouped[skill.level] = [] grouped[skill.level].append(skill) - + # Ordenar skills dentro de cada nivel por order_index for level in grouped: grouped[level] = sorted(grouped[level], key=lambda x: x.order_index) - + return grouped @@ -446,14 +442,14 @@ async def get_skills_grouped_by_level(): "/stats/summary", response_model=dict, summary="Estadísticas de habilidades", - description="Obtiene estadísticas sobre las habilidades del perfil" + description="Obtiene estadísticas sobre las habilidades del perfil", ) async def get_skills_stats(): """ Calcula estadísticas sobre las habilidades técnicas. - + Útil para mostrar métricas en el portfolio o admin panel. - + Returns: dict: Estadísticas Ejemplo: @@ -474,18 +470,16 @@ async def get_skills_stats(): "testing": 1 } } - + TODO: Implementar con GetSkillsStatsUseCase """ - stats = { - "total": len(MOCK_SKILLS), - "by_level": {}, - "by_category": {} - } - + stats: dict[str, Any] = {"total": len(MOCK_SKILLS), "by_level": {}, "by_category": {}} + # Contar por nivel for skill in MOCK_SKILLS: stats["by_level"][skill.level] = stats["by_level"].get(skill.level, 0) + 1 - stats["by_category"][skill.category] = stats["by_category"].get(skill.category, 0) + 1 - - return stats \ No newline at end of file + stats["by_category"][skill.category] = ( + stats["by_category"].get(skill.category, 0) + 1 + ) + + return stats diff --git a/app/api/v1/routers/social_networks_router.py b/app/api/v1/routers/social_networks_router.py index 76ebccb..b89ac7e 100644 --- a/app/api/v1/routers/social_networks_router.py +++ b/app/api/v1/routers/social_networks_router.py @@ -1,14 +1,14 @@ -from fastapi import APIRouter, HTTPException, status -from typing import List from datetime import datetime +from fastapi import APIRouter, HTTPException, status + +from app.api.schemas.common_schema import MessageResponse from app.api.schemas.social_networks_schema import ( - SocialNetworkResponse, SocialNetworkCreate, + SocialNetworkResponse, SocialNetworkUpdate, SocialPlatform, ) -from app.api.schemas.common_schema import MessageResponse router = APIRouter(prefix="/social-networks", tags=["Social Networks"]) @@ -73,7 +73,7 @@ @router.get( "", - response_model=List[SocialNetworkResponse], + response_model=list[SocialNetworkResponse], summary="Listar redes sociales", description="Obtiene todas las redes sociales ordenadas por orderIndex", ) @@ -134,7 +134,7 @@ async def get_social_network(social_id: str): summary="Crear red social", description="Crea una nueva red social asociada al perfil", ) -async def create_social_network(social_data: SocialNetworkCreate): +async def create_social_network(_social_data: SocialNetworkCreate): """ Crea una nueva red social y la asocia al perfil único del sistema. @@ -181,7 +181,7 @@ async def create_social_network(social_data: SocialNetworkCreate): summary="Actualizar red social", description="Actualiza una red social existente", ) -async def update_social_network(social_id: str, social_data: SocialNetworkUpdate): +async def update_social_network(social_id: str, _social_data: SocialNetworkUpdate): """ Actualiza una red social existente. @@ -262,11 +262,11 @@ async def delete_social_network(social_id: str): @router.patch( "/reorder", - response_model=List[SocialNetworkResponse], + response_model=list[SocialNetworkResponse], summary="Reordenar redes sociales", description="Actualiza el orderIndex de múltiples redes sociales de una vez", ) -async def reorder_social_networks(social_orders: List[dict]): +async def reorder_social_networks(_social_orders: list[dict]): """ Reordena múltiples redes sociales de una sola vez. @@ -298,7 +298,7 @@ async def reorder_social_networks(social_orders: List[dict]): @router.get( "/by-platform/{platform}", - response_model=List[SocialNetworkResponse], + response_model=list[SocialNetworkResponse], summary="Filtrar por plataforma", description="Obtiene redes sociales de una plataforma específica", ) @@ -345,7 +345,7 @@ async def get_social_networks_grouped(): TODO: Implementar con GetSocialNetworksGroupedUseCase TODO: Ordenar redes dentro de cada plataforma por order_index """ - grouped = {} + grouped: dict[str, list[SocialNetworkResponse]] = {} for social in MOCK_SOCIAL_NETWORKS: if social.platform not in grouped: grouped[social.platform] = [] diff --git a/app/api/v1/routers/tools_router.py b/app/api/v1/routers/tools_router.py index 381300b..077ac62 100644 --- a/app/api/v1/routers/tools_router.py +++ b/app/api/v1/routers/tools_router.py @@ -1,15 +1,16 @@ -from fastapi import APIRouter, HTTPException, status -from typing import List from datetime import datetime +from typing import Any + +from fastapi import APIRouter, HTTPException, status +from app.api.schemas.common_schema import MessageResponse from app.api.schemas.tools_schema import ( - ToolResponse, - ToolCreate, - ToolUpdate, ToolCategory, + ToolCreate, ToolKnowledgeLevel, + ToolResponse, + ToolUpdate, ) -from app.api.schemas.common_schema import MessageResponse router = APIRouter(prefix="/tools", tags=["Tools"]) @@ -193,12 +194,13 @@ @router.get( "", - response_model=List[ToolResponse], + response_model=list[ToolResponse], summary="Listar herramientas", description="Obtiene todas las herramientas del perfil", ) async def get_tools( - category: ToolCategory = None, knowledge_level: ToolKnowledgeLevel = None + category: ToolCategory | None = None, + knowledge_level: ToolKnowledgeLevel | None = None, ): """ Lista todas las herramientas del perfil único del sistema. @@ -272,7 +274,7 @@ async def get_tool(tool_id: str): summary="Crear herramienta", description="Crea una nueva herramienta asociada al perfil", ) -async def create_tool(tool_data: ToolCreate): +async def create_tool(_tool_data: ToolCreate): """ Crea una nueva herramienta y la asocia al perfil único del sistema. @@ -318,7 +320,7 @@ async def create_tool(tool_data: ToolCreate): summary="Actualizar herramienta", description="Actualiza una herramienta existente", ) -async def update_tool(tool_id: str, tool_data: ToolUpdate): +async def update_tool(tool_id: str, _tool_data: ToolUpdate): """ Actualiza una herramienta existente. @@ -391,11 +393,11 @@ async def delete_tool(tool_id: str): @router.patch( "/reorder", - response_model=List[ToolResponse], + response_model=list[ToolResponse], summary="Reordenar herramientas", description="Actualiza el orderIndex de múltiples herramientas de una vez", ) -async def reorder_tools(tool_orders: List[dict]): +async def reorder_tools(_tool_orders: list[dict]): """ Reordena múltiples herramientas de una sola vez. @@ -449,7 +451,7 @@ async def get_tools_grouped_by_category(): TODO: Ordenar tools dentro de cada categoría por order_index TODO: Considerar ordenar categorías por prioridad """ - grouped = {} + grouped: dict[str, list[ToolResponse]] = {} for tool in MOCK_TOOLS: if tool.category not in grouped: grouped[tool.category] = [] @@ -487,9 +489,9 @@ async def get_tools_grouped_by_knowledge_level(): TODO: Implementar con GetToolsGroupedByKnowledgeLevelUseCase TODO: Ordenar tools dentro de cada nivel por order_index """ - grouped = {} + grouped: dict[str, list[ToolResponse]] = {} for tool in MOCK_TOOLS: - level = tool.knowledge_level or "none" + level: str = tool.knowledge_level or "none" if level not in grouped: grouped[level] = [] grouped[level].append(tool) @@ -537,7 +539,7 @@ async def get_tools_stats(): TODO: Implementar con GetToolsStatsUseCase """ - stats = { + stats: dict[str, Any] = { "total": len(MOCK_TOOLS), "by_category": {}, "by_knowledge_level": {}, diff --git a/app/api/v1/routers/work_experience_router.py b/app/api/v1/routers/work_experience_router.py index f9e25da..73a4696 100644 --- a/app/api/v1/routers/work_experience_router.py +++ b/app/api/v1/routers/work_experience_router.py @@ -1,13 +1,13 @@ +from datetime import date, datetime + from fastapi import APIRouter, HTTPException, status -from typing import List -from datetime import datetime, date +from app.api.schemas.common_schema import MessageResponse from app.api.schemas.work_experience_schema import ( - WorkExperienceResponse, WorkExperienceCreate, - WorkExperienceUpdate + WorkExperienceResponse, + WorkExperienceUpdate, ) -from app.api.schemas.common_schema import MessageResponse router = APIRouter(prefix="/work-experiences", tags=["Work Experience"]) @@ -21,10 +21,18 @@ start_date=date(2021, 3, 1), end_date=None, # Actualmente trabajando description="Desarrollo de aplicaciones web escalables usando FastAPI y React. Implementación de arquitectura Clean Architecture en proyectos empresariales. Liderazgo técnico de equipo de 4 desarrolladores junior. Responsable de code reviews y definición de estándares de código.", - technologies=["Python", "FastAPI", "React", "MongoDB", "Docker", "AWS", "TypeScript"], + technologies=[ + "Python", + "FastAPI", + "React", + "MongoDB", + "Docker", + "AWS", + "TypeScript", + ], order_index=0, # Empleo actual, se muestra primero created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), WorkExperienceResponse( id="exp_002", @@ -37,7 +45,7 @@ technologies=["Node.js", "Vue.js", "PostgreSQL", "Stripe", "Docker", "Redis"], order_index=1, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), WorkExperienceResponse( id="exp_003", @@ -50,7 +58,7 @@ technologies=["PHP", "Laravel", "MySQL", "Redis", "jQuery"], order_index=2, created_at=datetime.now(), - updated_at=datetime.now() + updated_at=datetime.now(), ), WorkExperienceResponse( id="exp_004", @@ -63,31 +71,31 @@ technologies=["HTML", "CSS", "JavaScript", "WordPress", "PHP", "MySQL"], order_index=3, created_at=datetime.now(), - updated_at=datetime.now() - ) + updated_at=datetime.now(), + ), ] @router.get( "", - response_model=List[WorkExperienceResponse], + response_model=list[WorkExperienceResponse], summary="Listar experiencias laborales", - description="Obtiene todas las experiencias laborales ordenadas por orderIndex" + description="Obtiene todas las experiencias laborales ordenadas por orderIndex", ) async def get_work_experiences(): """ Lista todas las experiencias laborales del perfil único del sistema. - + Las experiencias se retornan ordenadas por `order_index` ascendente. Típicamente, el empleo actual o más reciente tiene order_index menor. - + Returns: List[WorkExperienceResponse]: Lista de experiencias ordenadas - + Relación: - Todas las experiencias pertenecen al Profile único del sistema - technologies se relaciona con Skills del perfil - + TODO: Implementar con GetWorkExperiencesUseCase TODO: Ordenar por order_index ASC (empleo actual primero, luego cronológico inverso) """ @@ -98,30 +106,30 @@ async def get_work_experiences(): "/{experience_id}", response_model=WorkExperienceResponse, summary="Obtener experiencia laboral", - description="Obtiene una experiencia laboral específica por ID" + description="Obtiene una experiencia laboral específica por ID", ) async def get_work_experience(experience_id: str): """ Obtiene una experiencia laboral por su ID. - + Args: experience_id: ID único de la experiencia - + Returns: WorkExperienceResponse: Experiencia encontrada - + Raises: HTTPException 404: Si la experiencia no existe - + TODO: Implementar con GetWorkExperienceUseCase """ for exp in MOCK_EXPERIENCES: if exp.id == experience_id: return exp - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Experiencia laboral con ID '{experience_id}' no encontrada" + detail=f"Experiencia laboral con ID '{experience_id}' no encontrada", ) @@ -130,34 +138,34 @@ async def get_work_experience(experience_id: str): response_model=WorkExperienceResponse, status_code=status.HTTP_201_CREATED, summary="Crear experiencia laboral", - description="Crea una nueva experiencia laboral asociada al perfil" + description="Crea una nueva experiencia laboral asociada al perfil", ) -async def create_work_experience(experience_data: WorkExperienceCreate): +async def create_work_experience(_experience_data: WorkExperienceCreate): """ Crea una nueva experiencia laboral y la asocia al perfil único del sistema. - + **Invariantes que se validan automáticamente:** - `role` no puede estar vacío (min_length=1) - `company` no puede estar vacía (min_length=1) - `startDate` es obligatoria - Si `endDate` existe, debe ser posterior a `startDate` (validador Pydantic) - `orderIndex` debe ser único dentro del perfil - + Args: experience_data: Datos de la experiencia a crear - + Returns: WorkExperienceResponse: Experiencia creada - + Raises: HTTPException 422: Si endDate <= startDate HTTPException 409: Si orderIndex ya está en uso HTTPException 400: Si los datos no cumplen las invariantes - + Notas sobre technologies: - Las tecnologías listadas deberían idealmente existir como Skills en el perfil - Esto ayuda a demostrar dónde se usaron las habilidades - + TODO: Implementar con CreateWorkExperienceUseCase TODO: Validar que orderIndex sea único dentro del perfil TODO: Considerar auto-incrementar orderIndex si no se proporciona @@ -169,7 +177,7 @@ async def create_work_experience(experience_data: WorkExperienceCreate): # status_code=status.HTTP_409_CONFLICT, # detail=f"Ya existe una experiencia con orderIndex {experience_data.order_index}" # ) - + return MOCK_EXPERIENCES[0] @@ -177,39 +185,38 @@ async def create_work_experience(experience_data: WorkExperienceCreate): "/{experience_id}", response_model=WorkExperienceResponse, summary="Actualizar experiencia laboral", - description="Actualiza una experiencia laboral existente" + description="Actualiza una experiencia laboral existente", ) async def update_work_experience( - experience_id: str, - experience_data: WorkExperienceUpdate + experience_id: str, _experience_data: WorkExperienceUpdate ): """ Actualiza una experiencia laboral existente. - + **Invariantes:** - Si se actualiza `role`, no puede estar vacío - Si se actualiza `company`, no puede estar vacía - Si se actualiza `endDate`, debe ser posterior a `startDate` - Si se actualiza `orderIndex`, debe ser único dentro del perfil - + Args: experience_id: ID de la experiencia a actualizar experience_data: Datos a actualizar (campos opcionales) - + Returns: WorkExperienceResponse: Experiencia actualizada - + Raises: HTTPException 404: Si la experiencia no existe HTTPException 422: Si endDate <= startDate HTTPException 409: Si el nuevo orderIndex ya está en uso HTTPException 400: Si los datos no cumplen las invariantes - + Casos de uso comunes: - Actualizar endDate cuando dejas un empleo (de None a fecha específica) - Actualizar description para añadir nuevos logros - Actualizar technologies si aprendes nuevas tecnologías en el trabajo - + TODO: Implementar con UpdateWorkExperienceUseCase TODO: Validar fechas si se actualizan TODO: Validar que orderIndex sea único si se actualiza @@ -219,17 +226,17 @@ async def update_work_experience( if exp.id == experience_id: # Mock: Validar orderIndex único si se actualiza # if experience_data.order_index is not None: - # if any(e.order_index == experience_data.order_index and e.id != experience_id + # if any(e.order_index == experience_data.order_index and e.id != experience_id # for e in MOCK_EXPERIENCES): # raise HTTPException( # status_code=status.HTTP_409_CONFLICT, # detail=f"Ya existe otra experiencia con orderIndex {experience_data.order_index}" # ) return exp - + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Experiencia laboral con ID '{experience_id}' no encontrada" + detail=f"Experiencia laboral con ID '{experience_id}' no encontrada", ) @@ -237,46 +244,46 @@ async def update_work_experience( "/{experience_id}", response_model=MessageResponse, summary="Eliminar experiencia laboral", - description="Elimina una experiencia laboral del perfil" + description="Elimina una experiencia laboral del perfil", ) async def delete_work_experience(experience_id: str): """ Elimina una experiencia laboral del perfil. - + Nota: Al eliminar una experiencia, puede ser necesario reordenar los orderIndex de las experiencias restantes para mantener la coherencia. - + Args: experience_id: ID de la experiencia a eliminar - + Returns: MessageResponse: Confirmación de eliminación - + Raises: HTTPException 404: Si la experiencia no existe - + TODO: Implementar con DeleteWorkExperienceUseCase TODO: Considerar reordenamiento automático de orderIndex TODO: Requiere autenticación de admin """ return MessageResponse( success=True, - message=f"Experiencia laboral '{experience_id}' eliminada correctamente" + message=f"Experiencia laboral '{experience_id}' eliminada correctamente", ) @router.patch( "/reorder", - response_model=List[WorkExperienceResponse], + response_model=list[WorkExperienceResponse], summary="Reordenar experiencias laborales", - description="Actualiza el orderIndex de múltiples experiencias de una vez" + description="Actualiza el orderIndex de múltiples experiencias de una vez", ) -async def reorder_work_experiences(experience_orders: List[dict]): +async def reorder_work_experiences(_experience_orders: list[dict]): """ Reordena múltiples experiencias laborales de una sola vez. - + Útil para drag & drop en el panel de administración. - + Args: experience_orders: Lista de objetos con {id, orderIndex} Ejemplo: [ @@ -284,14 +291,14 @@ async def reorder_work_experiences(experience_orders: List[dict]): {"id": "exp_002", "orderIndex": 1}, {"id": "exp_003", "orderIndex": 2} ] - + Returns: List[WorkExperienceResponse]: Experiencias reordenadas - + Raises: HTTPException 400: Si hay orderIndex duplicados HTTPException 404: Si algún experience_id no existe - + TODO: Implementar con ReorderWorkExperiencesUseCase TODO: Validar que todos los orderIndex sean únicos TODO: Validar que todos los experience_id existan @@ -303,23 +310,23 @@ async def reorder_work_experiences(experience_orders: List[dict]): @router.get( "/current/active", - response_model=List[WorkExperienceResponse], + response_model=list[WorkExperienceResponse], summary="Obtener empleos actuales", - description="Obtiene experiencias laborales donde aún se trabaja (endDate = None)" + description="Obtiene experiencias laborales donde aún se trabaja (endDate = None)", ) async def get_current_work_experiences(): """ Lista experiencias laborales actuales (sin endDate). - + Útil para mostrar el "trabajo actual" en el perfil. - + Returns: List[WorkExperienceResponse]: Experiencias sin endDate - + Nota: - Normalmente debería haber solo 1, pero el sistema permite múltiples (por ejemplo, si se trabaja part-time en varios lugares) - + TODO: Implementar con GetCurrentWorkExperiencesUseCase """ current = [exp for exp in MOCK_EXPERIENCES if exp.end_date is None] @@ -328,61 +335,59 @@ async def get_current_work_experiences(): @router.get( "/by-company/{company}", - response_model=List[WorkExperienceResponse], + response_model=list[WorkExperienceResponse], summary="Filtrar experiencias por empresa", - description="Obtiene experiencias laborales de una empresa específica" + description="Obtiene experiencias laborales de una empresa específica", ) async def get_experiences_by_company(company: str): """ Filtra experiencias laborales por empresa. - + Útil si trabajaste en la misma empresa en diferentes períodos o roles. - + Args: company: Nombre de la empresa (búsqueda case-insensitive) - + Returns: List[WorkExperienceResponse]: Experiencias en esa empresa - + TODO: Implementar con GetExperiencesByCompanyUseCase TODO: Hacer búsqueda case-insensitive y parcial (contains) """ company_lower = company.lower() - filtered = [ - exp for exp in MOCK_EXPERIENCES - if company_lower in exp.company.lower() - ] + filtered = [exp for exp in MOCK_EXPERIENCES if company_lower in exp.company.lower()] return sorted(filtered, key=lambda x: x.start_date, reverse=True) @router.get( "/by-technology/{technology}", - response_model=List[WorkExperienceResponse], + response_model=list[WorkExperienceResponse], summary="Filtrar experiencias por tecnología", - description="Obtiene experiencias donde se usó una tecnología específica" + description="Obtiene experiencias donde se usó una tecnología específica", ) async def get_experiences_by_technology(technology: str): """ Filtra experiencias laborales por tecnología usada. - + Útil para demostrar experiencia práctica con una tecnología específica. - + Args: technology: Nombre de la tecnología (case-insensitive) - + Returns: List[WorkExperienceResponse]: Experiencias que usaron esa tecnología - + Relación con Skills: - Este endpoint ayuda a vincular Skills con experiencia laboral real - Muestra dónde y cuándo se aplicó una habilidad - + TODO: Implementar con GetExperiencesByTechnologyUseCase TODO: Hacer búsqueda case-insensitive """ tech_lower = technology.lower() filtered = [ - exp for exp in MOCK_EXPERIENCES + exp + for exp in MOCK_EXPERIENCES if any(tech.lower() == tech_lower for tech in exp.technologies) ] - return sorted(filtered, key=lambda x: x.order_index) \ No newline at end of file + return sorted(filtered, key=lambda x: x.order_index) diff --git a/app/application/__init__.py b/app/application/__init__.py index 54e8f8f..94edd4f 100644 --- a/app/application/__init__.py +++ b/app/application/__init__.py @@ -17,4 +17,4 @@ └── __init__.py # This file """ -__version__ = "0.1.0" \ No newline at end of file +__version__ = "0.1.0" diff --git a/app/application/dto/__init__.py b/app/application/dto/__init__.py index 2ccb43f..04cd310 100644 --- a/app/application/dto/__init__.py +++ b/app/application/dto/__init__.py @@ -5,52 +5,42 @@ DTOs are simple data containers without business logic. """ -from .base_dto import ( - SuccessResponse, - ErrorResponse, - PaginationRequest, - DateRangeDTO, +from .base_dto import DateRangeDTO, ErrorResponse, PaginationRequest, SuccessResponse +from .cv_dto import ( + CompleteCVResponse, + GenerateCVPDFRequest, + GenerateCVPDFResponse, + GetCompleteCVRequest, +) +from .education_dto import ( + AddEducationRequest, + DeleteEducationRequest, + EditEducationRequest, + EducationListResponse, + EducationResponse, + ListEducationRequest, ) - from .profile_dto import ( CreateProfileRequest, - UpdateProfileRequest, GetProfileRequest, ProfileResponse, + UpdateProfileRequest, ) - -from .work_experience_dto import ( - AddExperienceRequest, - EditExperienceRequest, - DeleteExperienceRequest, - ListExperiencesRequest, - WorkExperienceResponse, - WorkExperienceListResponse, -) - from .skill_dto import ( AddSkillRequest, - EditSkillRequest, DeleteSkillRequest, + EditSkillRequest, ListSkillsRequest, - SkillResponse, SkillListResponse, + SkillResponse, ) - -from .education_dto import ( - AddEducationRequest, - EditEducationRequest, - DeleteEducationRequest, - ListEducationRequest, - EducationResponse, - EducationListResponse, -) - -from .cv_dto import ( - GetCompleteCVRequest, - CompleteCVResponse, - GenerateCVPDFRequest, - GenerateCVPDFResponse, +from .work_experience_dto import ( + AddExperienceRequest, + DeleteExperienceRequest, + EditExperienceRequest, + ListExperiencesRequest, + WorkExperienceListResponse, + WorkExperienceResponse, ) __all__ = [ @@ -90,4 +80,4 @@ "CompleteCVResponse", "GenerateCVPDFRequest", "GenerateCVPDFResponse", -] \ No newline at end of file +] diff --git a/app/application/dto/base_dto.py b/app/application/dto/base_dto.py index 9c32415..5513f1f 100644 --- a/app/application/dto/base_dto.py +++ b/app/application/dto/base_dto.py @@ -5,14 +5,14 @@ These are simple data containers without business logic. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime -from typing import Optional @dataclass class SuccessResponse: """Generic success response.""" + success: bool = True message: str = "Operation completed successfully" @@ -20,21 +20,19 @@ class SuccessResponse: @dataclass class ErrorResponse: """Generic error response.""" - success: bool = False - message: str - errors: list[str] = None - def __post_init__(self): - if self.errors is None: - self.errors = [] + message: str + success: bool = False + errors: list[str] = field(default_factory=list) @dataclass class PaginationRequest: """Pagination parameters for list queries.""" + skip: int = 0 limit: int = 100 - sort_by: Optional[str] = None + sort_by: str | None = None ascending: bool = True def __post_init__(self): @@ -49,5 +47,6 @@ def __post_init__(self): @dataclass class DateRangeDTO: """DTO for date ranges.""" + start_date: datetime - end_date: Optional[datetime] = None \ No newline at end of file + end_date: datetime | None = None diff --git a/app/application/dto/cv_dto.py b/app/application/dto/cv_dto.py index 3024271..b8ce065 100644 --- a/app/application/dto/cv_dto.py +++ b/app/application/dto/cv_dto.py @@ -5,26 +5,28 @@ """ from dataclasses import dataclass -from typing import List + +from .education_dto import EducationResponse from .profile_dto import ProfileResponse -from .work_experience_dto import WorkExperienceResponse from .skill_dto import SkillResponse -from .education_dto import EducationResponse +from .work_experience_dto import WorkExperienceResponse @dataclass class GetCompleteCVRequest: """Request to get complete CV (no parameters needed).""" + pass @dataclass class CompleteCVResponse: """Response containing complete CV data.""" + profile: ProfileResponse - experiences: List[WorkExperienceResponse] - skills: List[SkillResponse] - education: List[EducationResponse] + experiences: list[WorkExperienceResponse] + skills: list[SkillResponse] + education: list[EducationResponse] @classmethod def create( @@ -46,6 +48,7 @@ def create( @dataclass class GenerateCVPDFRequest: """Request to generate CV PDF.""" + format: str = "standard" # standard, compact, detailed include_photo: bool = True @@ -53,6 +56,7 @@ class GenerateCVPDFRequest: @dataclass class GenerateCVPDFResponse: """Response containing PDF generation result.""" + success: bool file_path: str - message: str = "PDF generated successfully" \ No newline at end of file + message: str = "PDF generated successfully" diff --git a/app/application/dto/education_dto.py b/app/application/dto/education_dto.py index 628cb7e..cc93b63 100644 --- a/app/application/dto/education_dto.py +++ b/app/application/dto/education_dto.py @@ -6,43 +6,46 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional, List @dataclass class AddEducationRequest: """Request to add education.""" + profile_id: str institution: str degree: str field: str start_date: datetime order_index: int - description: Optional[str] = None - end_date: Optional[datetime] = None + description: str | None = None + end_date: datetime | None = None @dataclass class EditEducationRequest: """Request to edit education.""" + education_id: str - institution: Optional[str] = None - degree: Optional[str] = None - field: Optional[str] = None - description: Optional[str] = None - start_date: Optional[datetime] = None - end_date: Optional[datetime] = None + institution: str | None = None + degree: str | None = None + field: str | None = None + description: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None @dataclass class DeleteEducationRequest: """Request to delete education.""" + education_id: str @dataclass class ListEducationRequest: """Request to list education entries.""" + profile_id: str ascending: bool = False # Default: newest first @@ -50,14 +53,15 @@ class ListEducationRequest: @dataclass class EducationResponse: """Response containing education data.""" + id: str profile_id: str institution: str degree: str field: str start_date: datetime - end_date: Optional[datetime] - description: Optional[str] + end_date: datetime | None + description: str | None order_index: int created_at: datetime updated_at: datetime @@ -85,7 +89,8 @@ def from_entity(cls, entity) -> "EducationResponse": @dataclass class EducationListResponse: """Response containing list of education entries.""" - education: List[EducationResponse] + + education: list[EducationResponse] total: int @classmethod @@ -94,4 +99,4 @@ def from_entities(cls, entities) -> "EducationListResponse": return cls( education=[EducationResponse.from_entity(e) for e in entities], total=len(entities), - ) \ No newline at end of file + ) diff --git a/app/application/dto/profile_dto.py b/app/application/dto/profile_dto.py index 8805174..e794dd1 100644 --- a/app/application/dto/profile_dto.py +++ b/app/application/dto/profile_dto.py @@ -6,44 +6,47 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional @dataclass class CreateProfileRequest: """Request to create a profile.""" + name: str headline: str - bio: Optional[str] = None - location: Optional[str] = None - avatar_url: Optional[str] = None + bio: str | None = None + location: str | None = None + avatar_url: str | None = None @dataclass class UpdateProfileRequest: """Request to update profile information.""" - name: Optional[str] = None - headline: Optional[str] = None - bio: Optional[str] = None - location: Optional[str] = None - avatar_url: Optional[str] = None + + name: str | None = None + headline: str | None = None + bio: str | None = None + location: str | None = None + avatar_url: str | None = None @dataclass class GetProfileRequest: """Request to get the profile (no parameters needed).""" + pass @dataclass class ProfileResponse: """Response containing profile data.""" + id: str name: str headline: str - bio: Optional[str] - location: Optional[str] - avatar_url: Optional[str] + bio: str | None + location: str | None + avatar_url: str | None created_at: datetime updated_at: datetime @@ -59,4 +62,4 @@ def from_entity(cls, entity) -> "ProfileResponse": avatar_url=entity.avatar_url, created_at=entity.created_at, updated_at=entity.updated_at, - ) \ No newline at end of file + ) diff --git a/app/application/dto/skill_dto.py b/app/application/dto/skill_dto.py index 3a68f42..0cca7dd 100644 --- a/app/application/dto/skill_dto.py +++ b/app/application/dto/skill_dto.py @@ -1,101 +1,88 @@ """ -Work Experience DTOs. +Skill DTOs. -Data Transfer Objects for Work Experience use cases. +Data Transfer Objects for Skill use cases. """ from dataclasses import dataclass -from datetime import datetime -from typing import Optional, List @dataclass -class AddExperienceRequest: - """Request to add a work experience.""" +class AddSkillRequest: + """Request to add a skill.""" + profile_id: str - role: str - company: str - start_date: datetime + name: str + category: str order_index: int - description: Optional[str] = None - end_date: Optional[datetime] = None - responsibilities: List[str] = None - - def __post_init__(self): - if self.responsibilities is None: - self.responsibilities = [] + level: str | None = None @dataclass -class EditExperienceRequest: - """Request to edit a work experience.""" - experience_id: str - role: Optional[str] = None - company: Optional[str] = None - description: Optional[str] = None - start_date: Optional[datetime] = None - end_date: Optional[datetime] = None - responsibilities: Optional[List[str]] = None +class EditSkillRequest: + """Request to edit a skill.""" + + skill_id: str + name: str | None = None + category: str | None = None + level: str | None = None @dataclass -class DeleteExperienceRequest: - """Request to delete a work experience.""" - experience_id: str +class DeleteSkillRequest: + """Request to delete a skill.""" + + skill_id: str @dataclass -class ListExperiencesRequest: - """Request to list work experiences.""" +class ListSkillsRequest: + """Request to list skills.""" + profile_id: str + category: str | None = None # Filter by category ascending: bool = False # Default: newest first @dataclass -class WorkExperienceResponse: - """Response containing work experience data.""" +class SkillResponse: + """Response containing skill data.""" + id: str profile_id: str - role: str - company: str - start_date: datetime - end_date: Optional[datetime] - description: Optional[str] - responsibilities: List[str] + name: str + category: str order_index: int - created_at: datetime - updated_at: datetime - is_current: bool + level: str | None + created_at: str + updated_at: str @classmethod - def from_entity(cls, entity) -> "WorkExperienceResponse": + def from_entity(cls, entity) -> "SkillResponse": """Create DTO from domain entity.""" return cls( id=entity.id, profile_id=entity.profile_id, - role=entity.role, - company=entity.company, - start_date=entity.start_date, - end_date=entity.end_date, - description=entity.description, - responsibilities=entity.responsibilities.copy(), + name=entity.name, + category=entity.category, order_index=entity.order_index, - created_at=entity.created_at, - updated_at=entity.updated_at, - is_current=entity.is_current_position(), + level=entity.level, + created_at=entity.created_at.isoformat(), + updated_at=entity.updated_at.isoformat(), ) @dataclass -class WorkExperienceListResponse: - """Response containing list of work experiences.""" - experiences: List[WorkExperienceResponse] +class SkillListResponse: + """Response containing list of skills.""" + + skills: list[SkillResponse] total: int @classmethod - def from_entities(cls, entities) -> "WorkExperienceListResponse": + def from_entities(cls, entities) -> "SkillListResponse": """Create DTO from list of domain entities.""" return cls( - experiences=[WorkExperienceResponse.from_entity(e) for e in entities], + skills=[SkillResponse.from_entity(s) for s in entities], total=len(entities), - ) \ No newline at end of file + ) diff --git a/app/application/dto/work_experience_dto.py b/app/application/dto/work_experience_dto.py index 3a68f42..999d567 100644 --- a/app/application/dto/work_experience_dto.py +++ b/app/application/dto/work_experience_dto.py @@ -4,49 +4,48 @@ Data Transfer Objects for Work Experience use cases. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime -from typing import Optional, List @dataclass class AddExperienceRequest: """Request to add a work experience.""" + profile_id: str role: str company: str start_date: datetime order_index: int - description: Optional[str] = None - end_date: Optional[datetime] = None - responsibilities: List[str] = None - - def __post_init__(self): - if self.responsibilities is None: - self.responsibilities = [] + description: str | None = None + end_date: datetime | None = None + responsibilities: list[str] = field(default_factory=list) @dataclass class EditExperienceRequest: """Request to edit a work experience.""" + experience_id: str - role: Optional[str] = None - company: Optional[str] = None - description: Optional[str] = None - start_date: Optional[datetime] = None - end_date: Optional[datetime] = None - responsibilities: Optional[List[str]] = None + role: str | None = None + company: str | None = None + description: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + responsibilities: list[str] | None = None @dataclass class DeleteExperienceRequest: """Request to delete a work experience.""" + experience_id: str @dataclass class ListExperiencesRequest: """Request to list work experiences.""" + profile_id: str ascending: bool = False # Default: newest first @@ -54,14 +53,15 @@ class ListExperiencesRequest: @dataclass class WorkExperienceResponse: """Response containing work experience data.""" + id: str profile_id: str role: str company: str start_date: datetime - end_date: Optional[datetime] - description: Optional[str] - responsibilities: List[str] + end_date: datetime | None + description: str | None + responsibilities: list[str] order_index: int created_at: datetime updated_at: datetime @@ -89,7 +89,8 @@ def from_entity(cls, entity) -> "WorkExperienceResponse": @dataclass class WorkExperienceListResponse: """Response containing list of work experiences.""" - experiences: List[WorkExperienceResponse] + + experiences: list[WorkExperienceResponse] total: int @classmethod @@ -98,4 +99,4 @@ def from_entities(cls, entities) -> "WorkExperienceListResponse": return cls( experiences=[WorkExperienceResponse.from_entity(e) for e in entities], total=len(entities), - ) \ No newline at end of file + ) diff --git a/app/application/use_cases/__init__.py b/app/application/use_cases/__init__.py index e4d8a0e..3e8484b 100644 --- a/app/application/use_cases/__init__.py +++ b/app/application/use_cases/__init__.py @@ -14,35 +14,20 @@ - cv/: CV aggregation and generation use cases """ -from .profile import ( - GetProfileUseCase, - CreateProfileUseCase, - UpdateProfileUseCase, -) - -from .work_experience import ( - AddExperienceUseCase, - EditExperienceUseCase, - DeleteExperienceUseCase, - ListExperiencesUseCase, -) - +from .cv import GenerateCVPDFUseCase, GetCompleteCVUseCase +from .education import AddEducationUseCase, DeleteEducationUseCase, EditEducationUseCase +from .profile import CreateProfileUseCase, GetProfileUseCase, UpdateProfileUseCase from .skill import ( AddSkillUseCase, - EditSkillUseCase, DeleteSkillUseCase, + EditSkillUseCase, ListSkillsUseCase, ) - -from .education import ( - AddEducationUseCase, - EditEducationUseCase, - DeleteEducationUseCase, -) - -from .cv import ( - GetCompleteCVUseCase, - GenerateCVPDFUseCase, +from .work_experience import ( + AddExperienceUseCase, + DeleteExperienceUseCase, + EditExperienceUseCase, + ListExperiencesUseCase, ) __all__ = [ @@ -67,4 +52,4 @@ # CV "GetCompleteCVUseCase", "GenerateCVPDFUseCase", -] \ No newline at end of file +] diff --git a/app/application/use_cases/cv/__init__.py b/app/application/use_cases/cv/__init__.py index b77ac59..0034651 100644 --- a/app/application/use_cases/cv/__init__.py +++ b/app/application/use_cases/cv/__init__.py @@ -4,10 +4,10 @@ Contains use cases for retrieving and generating the complete CV. """ -from .get_complete_cv import GetCompleteCVUseCase from .generate_cv_pdf import GenerateCVPDFUseCase +from .get_complete_cv import GetCompleteCVUseCase __all__ = [ "GetCompleteCVUseCase", "GenerateCVPDFUseCase", -] \ No newline at end of file +] diff --git a/app/application/use_cases/cv/generate_cv_pdf.py b/app/application/use_cases/cv/generate_cv_pdf.py index cacce7c..1a29304 100644 --- a/app/application/use_cases/cv/generate_cv_pdf.py +++ b/app/application/use_cases/cv/generate_cv_pdf.py @@ -4,36 +4,37 @@ Generates the CV as a PDF file (placeholder implementation). """ -from app.shared.interfaces import IQueryUseCase from app.application.dto import ( GenerateCVPDFRequest, GenerateCVPDFResponse, GetCompleteCVRequest, ) +from app.shared.interfaces import IQueryUseCase + from .get_complete_cv import GetCompleteCVUseCase class GenerateCVPDFUseCase(IQueryUseCase[GenerateCVPDFRequest, GenerateCVPDFResponse]): """ Use case for generating CV as PDF. - + Note: This is a placeholder for future PDF generation functionality. In a real implementation, this would: 1. Get complete CV data 2. Generate PDF using a template engine (e.g., ReportLab, WeasyPrint) 3. Save to file system or cloud storage 4. Return file path or URL - + For now, it returns a success response with a placeholder path. - + Business Rules: - Profile must exist - Multiple format options supported - Photo inclusion is configurable - + Dependencies: - GetCompleteCVUseCase: To get CV data - + Future Implementation: - PDF template engine integration - File storage service @@ -43,7 +44,7 @@ class GenerateCVPDFUseCase(IQueryUseCase[GenerateCVPDFRequest, GenerateCVPDFResp def __init__(self, get_cv_use_case: GetCompleteCVUseCase): """ Initialize use case with dependencies. - + Args: get_cv_use_case: Use case to get complete CV data """ @@ -52,32 +53,32 @@ def __init__(self, get_cv_use_case: GetCompleteCVUseCase): async def execute(self, request: GenerateCVPDFRequest) -> GenerateCVPDFResponse: """ Execute the use case. - + Args: request: Generate PDF request with format options - + Returns: GenerateCVPDFResponse with file path - + Note: This is a placeholder implementation. Actual PDF generation should be implemented in infrastructure layer. """ # Get CV data cv_data = await self.get_cv_use_case.execute(GetCompleteCVRequest()) - + # TODO: Implement actual PDF generation # This would involve: # 1. Creating a PDF template # 2. Populating it with cv_data # 3. Saving to storage # 4. Returning the path/URL - + # For now, return a placeholder response file_path = f"/tmp/cv_{cv_data.profile.id}_{request.format}.pdf" - + return GenerateCVPDFResponse( success=True, file_path=file_path, - message=f"PDF generation not yet implemented. Would generate {request.format} format at {file_path}" - ) \ No newline at end of file + message=f"PDF generation not yet implemented. Would generate {request.format} format at {file_path}", + ) diff --git a/app/application/use_cases/cv/get_complete_cv.py b/app/application/use_cases/cv/get_complete_cv.py index 2c92927..81401b7 100644 --- a/app/application/use_cases/cv/get_complete_cv.py +++ b/app/application/use_cases/cv/get_complete_cv.py @@ -4,39 +4,38 @@ Aggregates all CV data from multiple sources. """ +from typing import TYPE_CHECKING + +from app.application.dto import CompleteCVResponse, GetCompleteCVRequest from app.shared.interfaces import ( - IQueryUseCase, - IProfileRepository, IOrderedRepository, - IUniqueNameRepository + IProfileRepository, + IQueryUseCase, + IUniqueNameRepository, ) from app.shared.shared_exceptions import NotFoundException -from app.application.dto import GetCompleteCVRequest, CompleteCVResponse -from typing import TYPE_CHECKING if TYPE_CHECKING: - from app.domain.entities import ( - WorkExperience as WorkExperienceType, - Skill as SkillType, - Education as EducationType, - ) + from app.domain.entities import Education as EducationType + from app.domain.entities import Skill as SkillType + from app.domain.entities import WorkExperience as WorkExperienceType class GetCompleteCVUseCase(IQueryUseCase[GetCompleteCVRequest, CompleteCVResponse]): """ Use case for retrieving the complete CV. - + Aggregates data from multiple sources: - Profile - Work Experiences - Skills - Education - + Business Rules: - Profile must exist - All lists are ordered appropriately - Empty lists are returned if no data exists - + Dependencies: - IProfileRepository: For profile data - IOrderedRepository[WorkExperience]: For experiences @@ -47,13 +46,13 @@ class GetCompleteCVUseCase(IQueryUseCase[GetCompleteCVRequest, CompleteCVRespons def __init__( self, profile_repository: IProfileRepository, - experience_repository: IOrderedRepository['WorkExperienceType'], - skill_repository: IUniqueNameRepository['SkillType'], - education_repository: IOrderedRepository['EducationType'], + experience_repository: IOrderedRepository["WorkExperienceType"], + skill_repository: IUniqueNameRepository["SkillType"], + education_repository: IOrderedRepository["EducationType"], ): """ Initialize use case with dependencies. - + Args: profile_repository: Profile repository interface experience_repository: Experience repository interface @@ -65,16 +64,16 @@ def __init__( self.skill_repo = skill_repository self.education_repo = education_repository - async def execute(self, request: GetCompleteCVRequest) -> CompleteCVResponse: + async def execute(self, _request: GetCompleteCVRequest) -> CompleteCVResponse: """ Execute the use case. - + Args: request: Get complete CV request (empty) - + Returns: CompleteCVResponse with all CV data - + Raises: NotFoundException: If profile doesn't exist """ @@ -82,28 +81,26 @@ async def execute(self, request: GetCompleteCVRequest) -> CompleteCVResponse: profile = await self.profile_repo.get_profile() if not profile: raise NotFoundException("Profile", "single") - + # Get experiences (ordered by orderIndex, newest first) experiences = await self.experience_repo.get_all_ordered( - profile_id=profile.id, - ascending=False + profile_id=profile.id, ascending=False ) - + # Get skills (find all by profile_id) skills = await self.skill_repo.find_by(profile_id=profile.id) # Sort by order_index skills.sort(key=lambda s: s.order_index) - + # Get education (ordered by orderIndex, newest first) education = await self.education_repo.get_all_ordered( - profile_id=profile.id, - ascending=False + profile_id=profile.id, ascending=False ) - + # Aggregate and return return CompleteCVResponse.create( profile=profile, experiences=experiences, skills=skills, education=education, - ) \ No newline at end of file + ) diff --git a/app/application/use_cases/education/__init__.py b/app/application/use_cases/education/__init__.py index 02e237b..2850bda 100644 --- a/app/application/use_cases/education/__init__.py +++ b/app/application/use_cases/education/__init__.py @@ -5,11 +5,11 @@ """ from .add_education import AddEducationUseCase -from .edit_education import EditEducationUseCase from .delete_education import DeleteEducationUseCase +from .edit_education import EditEducationUseCase __all__ = [ "AddEducationUseCase", "EditEducationUseCase", "DeleteEducationUseCase", -] \ No newline at end of file +] diff --git a/app/application/use_cases/education/add_education.py b/app/application/use_cases/education/add_education.py index 7a6ea39..544d159 100644 --- a/app/application/use_cases/education/add_education.py +++ b/app/application/use_cases/education/add_education.py @@ -4,11 +4,12 @@ Adds a new education entry to the profile. """ -from app.shared.interfaces import ICommandUseCase, IOrderedRepository -from app.shared.shared_exceptions import BusinessRuleViolationException +from typing import TYPE_CHECKING + from app.application.dto import AddEducationRequest, EducationResponse from app.domain.entities import Education -from typing import TYPE_CHECKING +from app.shared.interfaces import ICommandUseCase, IOrderedRepository +from app.shared.shared_exceptions import BusinessRuleViolationException if TYPE_CHECKING: from app.domain.entities import Education as EducationType @@ -17,20 +18,20 @@ class AddEducationUseCase(ICommandUseCase[AddEducationRequest, EducationResponse]): """ Use case for adding education. - + Business Rules: - orderIndex must be unique per profile - All required fields must be provided - Date range must be valid - + Dependencies: - IOrderedRepository[Education]: For education data access """ - def __init__(self, education_repository: IOrderedRepository['EducationType']): + def __init__(self, education_repository: IOrderedRepository["EducationType"]): """ Initialize use case with dependencies. - + Args: education_repository: Education repository interface """ @@ -39,28 +40,27 @@ def __init__(self, education_repository: IOrderedRepository['EducationType']): async def execute(self, request: AddEducationRequest) -> EducationResponse: """ Execute the use case. - + Args: request: Add education request with education data - + Returns: EducationResponse with created education data - + Raises: BusinessRuleViolationException: If orderIndex already exists DomainError: If validation fails """ # Check orderIndex uniqueness existing = await self.education_repo.get_by_order_index( - request.profile_id, - request.order_index + request.profile_id, request.order_index ) if existing: raise BusinessRuleViolationException( "orderIndex must be unique per profile", - {"orderIndex": request.order_index} + {"orderIndex": request.order_index}, ) - + # Create domain entity (validates automatically) education = Education.create( profile_id=request.profile_id, @@ -72,9 +72,9 @@ async def execute(self, request: AddEducationRequest) -> EducationResponse: description=request.description, end_date=request.end_date, ) - + # Persist the education created_education = await self.education_repo.add(education) - + # Convert to DTO and return - return EducationResponse.from_entity(created_education) \ No newline at end of file + return EducationResponse.from_entity(created_education) diff --git a/app/application/use_cases/education/delete_education.py b/app/application/use_cases/education/delete_education.py index a7b9b30..49c62d0 100644 --- a/app/application/use_cases/education/delete_education.py +++ b/app/application/use_cases/education/delete_education.py @@ -4,10 +4,11 @@ Deletes an existing education entry. """ +from typing import TYPE_CHECKING + +from app.application.dto import DeleteEducationRequest, SuccessResponse from app.shared.interfaces import ICommandUseCase, IOrderedRepository from app.shared.shared_exceptions import NotFoundException -from app.application.dto import DeleteEducationRequest, SuccessResponse -from typing import TYPE_CHECKING if TYPE_CHECKING: from app.domain.entities import Education as EducationType @@ -16,19 +17,19 @@ class DeleteEducationUseCase(ICommandUseCase[DeleteEducationRequest, SuccessResponse]): """ Use case for deleting education. - + Business Rules: - Education must exist - Deletion is permanent - + Dependencies: - IOrderedRepository[Education]: For education data access """ - def __init__(self, education_repository: IOrderedRepository['EducationType']): + def __init__(self, education_repository: IOrderedRepository["EducationType"]): """ Initialize use case with dependencies. - + Args: education_repository: Education repository interface """ @@ -37,20 +38,20 @@ def __init__(self, education_repository: IOrderedRepository['EducationType']): async def execute(self, request: DeleteEducationRequest) -> SuccessResponse: """ Execute the use case. - + Args: request: Delete education request with education ID - + Returns: SuccessResponse confirming deletion - + Raises: NotFoundException: If education doesn't exist """ # Attempt to delete deleted = await self.education_repo.delete(request.education_id) - + if not deleted: raise NotFoundException("Education", request.education_id) - - return SuccessResponse(message="Education deleted successfully") \ No newline at end of file + + return SuccessResponse(message="Education deleted successfully") diff --git a/app/application/use_cases/education/edit_education.py b/app/application/use_cases/education/edit_education.py index 2c5e03e..00747a9 100644 --- a/app/application/use_cases/education/edit_education.py +++ b/app/application/use_cases/education/edit_education.py @@ -4,10 +4,11 @@ Updates an existing education entry. """ +from typing import TYPE_CHECKING + +from app.application.dto import EditEducationRequest, EducationResponse from app.shared.interfaces import ICommandUseCase, IOrderedRepository from app.shared.shared_exceptions import NotFoundException -from app.application.dto import EditEducationRequest, EducationResponse -from typing import TYPE_CHECKING if TYPE_CHECKING: from app.domain.entities import Education as EducationType @@ -16,20 +17,20 @@ class EditEducationUseCase(ICommandUseCase[EditEducationRequest, EducationResponse]): """ Use case for editing education. - + Business Rules: - Education must exist - Only provided fields are updated - Validations are performed by the entity - + Dependencies: - IOrderedRepository[Education]: For education data access """ - def __init__(self, education_repository: IOrderedRepository['EducationType']): + def __init__(self, education_repository: IOrderedRepository["EducationType"]): """ Initialize use case with dependencies. - + Args: education_repository: Education repository interface """ @@ -38,23 +39,23 @@ def __init__(self, education_repository: IOrderedRepository['EducationType']): async def execute(self, request: EditEducationRequest) -> EducationResponse: """ Execute the use case. - + Args: request: Edit education request with fields to update - + Returns: EducationResponse with updated education data - + Raises: NotFoundException: If education doesn't exist DomainError: If validation fails """ # Get existing education education = await self.education_repo.get_by_id(request.education_id) - + if not education: raise NotFoundException("Education", request.education_id) - + # Update info (entity validates) education.update_info( institution=request.institution, @@ -64,9 +65,9 @@ async def execute(self, request: EditEducationRequest) -> EducationResponse: start_date=request.start_date, end_date=request.end_date, ) - + # Persist changes updated_education = await self.education_repo.update(education) - + # Convert to DTO and return - return EducationResponse.from_entity(updated_education) \ No newline at end of file + return EducationResponse.from_entity(updated_education) diff --git a/app/application/use_cases/profile/__init__.py b/app/application/use_cases/profile/__init__.py index c2220ee..4889cce 100644 --- a/app/application/use_cases/profile/__init__.py +++ b/app/application/use_cases/profile/__init__.py @@ -4,12 +4,12 @@ Contains all use cases related to profile management. """ -from .get_profile import GetProfileUseCase from .create_profile import CreateProfileUseCase +from .get_profile import GetProfileUseCase from .update_profile import UpdateProfileUseCase __all__ = [ "GetProfileUseCase", "CreateProfileUseCase", "UpdateProfileUseCase", -] \ No newline at end of file +] diff --git a/app/application/use_cases/profile/create_profile.py b/app/application/use_cases/profile/create_profile.py index ec04128..e612c51 100644 --- a/app/application/use_cases/profile/create_profile.py +++ b/app/application/use_cases/profile/create_profile.py @@ -4,21 +4,21 @@ Creates the single profile in the system. """ -from app.shared.interfaces import ICommandUseCase, IProfileRepository -from app.shared.shared_exceptions import DuplicateException from app.application.dto import CreateProfileRequest, ProfileResponse from app.domain.entities import Profile +from app.shared.interfaces import ICommandUseCase, IProfileRepository +from app.shared.shared_exceptions import DuplicateException class CreateProfileUseCase(ICommandUseCase[CreateProfileRequest, ProfileResponse]): """ Use case for creating a profile. - + Business Rules: - Only ONE profile can exist in the system - All required fields must be provided - Avatar URL must be valid if provided - + Dependencies: - IProfileRepository: For profile data access """ @@ -26,7 +26,7 @@ class CreateProfileUseCase(ICommandUseCase[CreateProfileRequest, ProfileResponse def __init__(self, profile_repository: IProfileRepository): """ Initialize use case with dependencies. - + Args: profile_repository: Profile repository interface """ @@ -35,13 +35,13 @@ def __init__(self, profile_repository: IProfileRepository): async def execute(self, request: CreateProfileRequest) -> ProfileResponse: """ Execute the use case. - + Args: request: Create profile request with profile data - + Returns: ProfileResponse with created profile data - + Raises: DuplicateException: If a profile already exists DomainError: If validation fails @@ -51,9 +51,9 @@ async def execute(self, request: CreateProfileRequest) -> ProfileResponse: raise DuplicateException( "Profile", "single", - "A profile already exists. Only one profile is allowed." + "A profile already exists. Only one profile is allowed.", ) - + # Create domain entity (validates automatically) profile = Profile.create( name=request.name, @@ -62,9 +62,9 @@ async def execute(self, request: CreateProfileRequest) -> ProfileResponse: location=request.location, avatar_url=request.avatar_url, ) - + # Persist the profile created_profile = await self.profile_repo.add(profile) - + # Convert to DTO and return - return ProfileResponse.from_entity(created_profile) \ No newline at end of file + return ProfileResponse.from_entity(created_profile) diff --git a/app/application/use_cases/profile/get_profile.py b/app/application/use_cases/profile/get_profile.py index 4a31ea2..ce8e3cb 100644 --- a/app/application/use_cases/profile/get_profile.py +++ b/app/application/use_cases/profile/get_profile.py @@ -4,19 +4,19 @@ Retrieves the single profile that exists in the system. """ -from app.shared.interfaces import IQueryUseCase, IProfileRepository -from app.shared.shared_exceptions import NotFoundException from app.application.dto import GetProfileRequest, ProfileResponse +from app.shared.interfaces import IProfileRepository, IQueryUseCase +from app.shared.shared_exceptions import NotFoundException class GetProfileUseCase(IQueryUseCase[GetProfileRequest, ProfileResponse]): """ Use case for retrieving the profile. - + Business Rules: - Only one profile exists in the system - Returns NotFoundException if no profile exists - + Dependencies: - IProfileRepository: For profile data access """ @@ -24,31 +24,31 @@ class GetProfileUseCase(IQueryUseCase[GetProfileRequest, ProfileResponse]): def __init__(self, profile_repository: IProfileRepository): """ Initialize use case with dependencies. - + Args: profile_repository: Profile repository interface """ self.profile_repo = profile_repository - async def execute(self, request: GetProfileRequest) -> ProfileResponse: + async def execute(self, _request: GetProfileRequest) -> ProfileResponse: """ Execute the use case. - + Args: request: Get profile request (empty) - + Returns: ProfileResponse with profile data - + Raises: NotFoundException: If no profile exists """ # Get the profile profile = await self.profile_repo.get_profile() - + # Validate profile exists if not profile: raise NotFoundException("Profile", "single") - + # Convert to DTO and return - return ProfileResponse.from_entity(profile) \ No newline at end of file + return ProfileResponse.from_entity(profile) diff --git a/app/application/use_cases/profile/update_profile.py b/app/application/use_cases/profile/update_profile.py index a7473cb..4ebdc10 100644 --- a/app/application/use_cases/profile/update_profile.py +++ b/app/application/use_cases/profile/update_profile.py @@ -4,20 +4,20 @@ Updates the existing profile with new data. """ +from app.application.dto import ProfileResponse, UpdateProfileRequest from app.shared.interfaces import ICommandUseCase, IProfileRepository from app.shared.shared_exceptions import NotFoundException -from app.application.dto import UpdateProfileRequest, ProfileResponse class UpdateProfileUseCase(ICommandUseCase[UpdateProfileRequest, ProfileResponse]): """ Use case for updating profile information. - + Business Rules: - Profile must exist - Only provided fields are updated (partial update) - Validations are performed by the entity - + Dependencies: - IProfileRepository: For profile data access """ @@ -25,7 +25,7 @@ class UpdateProfileUseCase(ICommandUseCase[UpdateProfileRequest, ProfileResponse def __init__(self, profile_repository: IProfileRepository): """ Initialize use case with dependencies. - + Args: profile_repository: Profile repository interface """ @@ -34,23 +34,23 @@ def __init__(self, profile_repository: IProfileRepository): async def execute(self, request: UpdateProfileRequest) -> ProfileResponse: """ Execute the use case. - + Args: request: Update profile request with fields to update - + Returns: ProfileResponse with updated profile data - + Raises: NotFoundException: If profile doesn't exist DomainError: If validation fails """ # Get existing profile profile = await self.profile_repo.get_profile() - + if not profile: raise NotFoundException("Profile", "single") - + # Update basic info (entity validates) profile.update_basic_info( name=request.name, @@ -58,13 +58,13 @@ async def execute(self, request: UpdateProfileRequest) -> ProfileResponse: bio=request.bio, location=request.location, ) - + # Update avatar if provided if request.avatar_url is not None: profile.update_avatar(request.avatar_url) - + # Persist changes updated_profile = await self.profile_repo.update(profile) - + # Convert to DTO and return - return ProfileResponse.from_entity(updated_profile) \ No newline at end of file + return ProfileResponse.from_entity(updated_profile) diff --git a/app/application/use_cases/skill/__init__.py b/app/application/use_cases/skill/__init__.py index 8740ae6..1a57828 100644 --- a/app/application/use_cases/skill/__init__.py +++ b/app/application/use_cases/skill/__init__.py @@ -5,8 +5,8 @@ """ from .add_skill import AddSkillUseCase -from .edit_skill import EditSkillUseCase from .delete_skill import DeleteSkillUseCase +from .edit_skill import EditSkillUseCase from .list_skills import ListSkillsUseCase __all__ = [ @@ -14,4 +14,4 @@ "EditSkillUseCase", "DeleteSkillUseCase", "ListSkillsUseCase", -] \ No newline at end of file +] diff --git a/app/application/use_cases/skill/add_skill.py b/app/application/use_cases/skill/add_skill.py index 20be590..d61c8a4 100644 --- a/app/application/use_cases/skill/add_skill.py +++ b/app/application/use_cases/skill/add_skill.py @@ -4,11 +4,12 @@ Adds a new skill to the profile. """ -from app.shared.interfaces import ICommandUseCase, IUniqueNameRepository -from app.shared.shared_exceptions import DuplicateException +from typing import TYPE_CHECKING + from app.application.dto import AddSkillRequest, SkillResponse from app.domain.entities import Skill -from typing import TYPE_CHECKING +from app.shared.interfaces import ICommandUseCase, IUniqueNameRepository +from app.shared.shared_exceptions import DuplicateException if TYPE_CHECKING: from app.domain.entities import Skill as SkillType @@ -17,21 +18,21 @@ class AddSkillUseCase(ICommandUseCase[AddSkillRequest, SkillResponse]): """ Use case for adding a skill. - + Business Rules: - Name must be unique per profile - orderIndex must be unique per profile - Category is required - Level is optional - + Dependencies: - IUniqueNameRepository[Skill]: For skill data access """ - def __init__(self, skill_repository: IUniqueNameRepository['SkillType']): + def __init__(self, skill_repository: IUniqueNameRepository["SkillType"]): """ Initialize use case with dependencies. - + Args: skill_repository: Skill repository interface """ @@ -40,13 +41,13 @@ def __init__(self, skill_repository: IUniqueNameRepository['SkillType']): async def execute(self, request: AddSkillRequest) -> SkillResponse: """ Execute the use case. - + Args: request: Add skill request with skill data - + Returns: SkillResponse with created skill data - + Raises: DuplicateException: If skill name already exists DomainError: If validation fails @@ -54,7 +55,7 @@ async def execute(self, request: AddSkillRequest) -> SkillResponse: # Check name uniqueness if await self.skill_repo.exists_by_name(request.profile_id, request.name): raise DuplicateException("Skill", "name", request.name) - + # Create domain entity (validates automatically) skill = Skill.create( profile_id=request.profile_id, @@ -63,9 +64,9 @@ async def execute(self, request: AddSkillRequest) -> SkillResponse: order_index=request.order_index, level=request.level, ) - + # Persist the skill created_skill = await self.skill_repo.add(skill) - + # Convert to DTO and return - return SkillResponse.from_entity(created_skill) \ No newline at end of file + return SkillResponse.from_entity(created_skill) diff --git a/app/application/use_cases/skill/delete_skill.py b/app/application/use_cases/skill/delete_skill.py index e20f5f9..5d59763 100644 --- a/app/application/use_cases/skill/delete_skill.py +++ b/app/application/use_cases/skill/delete_skill.py @@ -4,10 +4,11 @@ Deletes an existing skill. """ +from typing import TYPE_CHECKING + +from app.application.dto import DeleteSkillRequest, SuccessResponse from app.shared.interfaces import ICommandUseCase, IUniqueNameRepository from app.shared.shared_exceptions import NotFoundException -from app.application.dto import DeleteSkillRequest, SuccessResponse -from typing import TYPE_CHECKING if TYPE_CHECKING: from app.domain.entities import Skill as SkillType @@ -16,19 +17,19 @@ class DeleteSkillUseCase(ICommandUseCase[DeleteSkillRequest, SuccessResponse]): """ Use case for deleting a skill. - + Business Rules: - Skill must exist - Deletion is permanent - + Dependencies: - IUniqueNameRepository[Skill]: For skill data access """ - def __init__(self, skill_repository: IUniqueNameRepository['SkillType']): + def __init__(self, skill_repository: IUniqueNameRepository["SkillType"]): """ Initialize use case with dependencies. - + Args: skill_repository: Skill repository interface """ @@ -37,20 +38,20 @@ def __init__(self, skill_repository: IUniqueNameRepository['SkillType']): async def execute(self, request: DeleteSkillRequest) -> SuccessResponse: """ Execute the use case. - + Args: request: Delete skill request with skill ID - + Returns: SuccessResponse confirming deletion - + Raises: NotFoundException: If skill doesn't exist """ # Attempt to delete deleted = await self.skill_repo.delete(request.skill_id) - + if not deleted: raise NotFoundException("Skill", request.skill_id) - - return SuccessResponse(message="Skill deleted successfully") \ No newline at end of file + + return SuccessResponse(message="Skill deleted successfully") diff --git a/app/application/use_cases/skill/edit_skill.py b/app/application/use_cases/skill/edit_skill.py index 536e638..38588ad 100644 --- a/app/application/use_cases/skill/edit_skill.py +++ b/app/application/use_cases/skill/edit_skill.py @@ -4,10 +4,11 @@ Updates an existing skill. """ +from typing import TYPE_CHECKING + +from app.application.dto import EditSkillRequest, SkillResponse from app.shared.interfaces import ICommandUseCase, IUniqueNameRepository from app.shared.shared_exceptions import DuplicateException, NotFoundException -from app.application.dto import EditSkillRequest, SkillResponse -from typing import TYPE_CHECKING if TYPE_CHECKING: from app.domain.entities import Skill as SkillType @@ -16,21 +17,21 @@ class EditSkillUseCase(ICommandUseCase[EditSkillRequest, SkillResponse]): """ Use case for editing a skill. - + Business Rules: - Skill must exist - Name must be unique if changed - Only provided fields are updated - Validations are performed by the entity - + Dependencies: - IUniqueNameRepository[Skill]: For skill data access """ - def __init__(self, skill_repository: IUniqueNameRepository['SkillType']): + def __init__(self, skill_repository: IUniqueNameRepository["SkillType"]): """ Initialize use case with dependencies. - + Args: skill_repository: Skill repository interface """ @@ -39,13 +40,13 @@ def __init__(self, skill_repository: IUniqueNameRepository['SkillType']): async def execute(self, request: EditSkillRequest) -> SkillResponse: """ Execute the use case. - + Args: request: Edit skill request with fields to update - + Returns: SkillResponse with updated skill data - + Raises: NotFoundException: If skill doesn't exist DuplicateException: If new name already exists @@ -53,24 +54,27 @@ async def execute(self, request: EditSkillRequest) -> SkillResponse: """ # Get existing skill skill = await self.skill_repo.get_by_id(request.skill_id) - + if not skill: raise NotFoundException("Skill", request.skill_id) - + # Check name uniqueness if changing name - if request.name and request.name != skill.name: - if await self.skill_repo.exists_by_name(skill.profile_id, request.name): - raise DuplicateException("Skill", "name", request.name) - + if ( + request.name + and request.name != skill.name + and await self.skill_repo.exists_by_name(skill.profile_id, request.name) + ): + raise DuplicateException("Skill", "name", request.name) + # Update info (entity validates) skill.update_info( name=request.name, category=request.category, level=request.level, ) - + # Persist changes updated_skill = await self.skill_repo.update(skill) - + # Convert to DTO and return - return SkillResponse.from_entity(updated_skill) \ No newline at end of file + return SkillResponse.from_entity(updated_skill) diff --git a/app/application/use_cases/skill/list_skills.py b/app/application/use_cases/skill/list_skills.py index 56f7a77..ae11a48 100644 --- a/app/application/use_cases/skill/list_skills.py +++ b/app/application/use_cases/skill/list_skills.py @@ -4,10 +4,11 @@ Retrieves all skills for a profile, optionally filtered by category. """ -from app.shared.interfaces import IQueryUseCase, IUniqueNameRepository -from app.application.dto import ListSkillsRequest, SkillListResponse from typing import TYPE_CHECKING +from app.application.dto import ListSkillsRequest, SkillListResponse +from app.shared.interfaces import IQueryUseCase, IUniqueNameRepository + if TYPE_CHECKING: from app.domain.entities import Skill as SkillType @@ -15,20 +16,20 @@ class ListSkillsUseCase(IQueryUseCase[ListSkillsRequest, SkillListResponse]): """ Use case for listing all skills. - + Business Rules: - Returns all skills for the profile - Optional filter by category - Ordered by orderIndex (configurable direction) - + Dependencies: - IUniqueNameRepository[Skill]: For skill data access """ - def __init__(self, skill_repository: IUniqueNameRepository['SkillType']): + def __init__(self, skill_repository: IUniqueNameRepository["SkillType"]): """ Initialize use case with dependencies. - + Args: skill_repository: Skill repository interface """ @@ -37,24 +38,23 @@ def __init__(self, skill_repository: IUniqueNameRepository['SkillType']): async def execute(self, request: ListSkillsRequest) -> SkillListResponse: """ Execute the use case. - + Args: request: List skills request with profile ID and optional filters - + Returns: SkillListResponse with list of skills and metadata """ # Get skills (filtered by category if provided) if request.category: skills = await self.skill_repo.find_by( - profile_id=request.profile_id, - category=request.category + profile_id=request.profile_id, category=request.category ) else: skills = await self.skill_repo.find_by(profile_id=request.profile_id) - + # Sort by order_index skills.sort(key=lambda s: s.order_index, reverse=not request.ascending) - + # Convert to DTO and return - return SkillListResponse.from_entities(skills) \ No newline at end of file + return SkillListResponse.from_entities(skills) diff --git a/app/application/use_cases/work_experience/__init__.py b/app/application/use_cases/work_experience/__init__.py index 850a149..a7988da 100644 --- a/app/application/use_cases/work_experience/__init__.py +++ b/app/application/use_cases/work_experience/__init__.py @@ -5,8 +5,8 @@ """ from .add_experience import AddExperienceUseCase -from .edit_experience import EditExperienceUseCase from .delete_experience import DeleteExperienceUseCase +from .edit_experience import EditExperienceUseCase from .list_experiences import ListExperiencesUseCase __all__ = [ @@ -14,4 +14,4 @@ "EditExperienceUseCase", "DeleteExperienceUseCase", "ListExperiencesUseCase", -] \ No newline at end of file +] diff --git a/app/application/use_cases/work_experience/add_experience.py b/app/application/use_cases/work_experience/add_experience.py index 9ee31eb..f6905ed 100644 --- a/app/application/use_cases/work_experience/add_experience.py +++ b/app/application/use_cases/work_experience/add_experience.py @@ -4,33 +4,36 @@ Adds a new work experience to the profile. """ -from app.shared.interfaces import ICommandUseCase, IOrderedRepository -from app.shared.shared_exceptions import BusinessRuleViolationException +from typing import TYPE_CHECKING + from app.application.dto import AddExperienceRequest, WorkExperienceResponse from app.domain.entities import WorkExperience -from typing import TYPE_CHECKING +from app.shared.interfaces import ICommandUseCase, IOrderedRepository +from app.shared.shared_exceptions import BusinessRuleViolationException if TYPE_CHECKING: from app.domain.entities import WorkExperience as WorkExperienceType -class AddExperienceUseCase(ICommandUseCase[AddExperienceRequest, WorkExperienceResponse]): +class AddExperienceUseCase( + ICommandUseCase[AddExperienceRequest, WorkExperienceResponse] +): """ Use case for adding a work experience. - + Business Rules: - orderIndex must be unique per profile - All required fields must be provided - Date range must be valid - + Dependencies: - IOrderedRepository[WorkExperience]: For experience data access """ - def __init__(self, experience_repository: IOrderedRepository['WorkExperienceType']): + def __init__(self, experience_repository: IOrderedRepository["WorkExperienceType"]): """ Initialize use case with dependencies. - + Args: experience_repository: Work experience repository interface """ @@ -39,28 +42,27 @@ def __init__(self, experience_repository: IOrderedRepository['WorkExperienceType async def execute(self, request: AddExperienceRequest) -> WorkExperienceResponse: """ Execute the use case. - + Args: request: Add experience request with experience data - + Returns: WorkExperienceResponse with created experience data - + Raises: BusinessRuleViolationException: If orderIndex already exists DomainError: If validation fails """ # Check if orderIndex is already used existing = await self.experience_repo.get_by_order_index( - request.profile_id, - request.order_index + request.profile_id, request.order_index ) if existing: raise BusinessRuleViolationException( "orderIndex must be unique per profile", - {"orderIndex": request.order_index} + {"orderIndex": request.order_index}, ) - + # Create domain entity (validates automatically) experience = WorkExperience.create( profile_id=request.profile_id, @@ -72,9 +74,9 @@ async def execute(self, request: AddExperienceRequest) -> WorkExperienceResponse end_date=request.end_date, responsibilities=request.responsibilities, ) - + # Persist the experience created_experience = await self.experience_repo.add(experience) - + # Convert to DTO and return - return WorkExperienceResponse.from_entity(created_experience) \ No newline at end of file + return WorkExperienceResponse.from_entity(created_experience) diff --git a/app/application/use_cases/work_experience/delete_experience.py b/app/application/use_cases/work_experience/delete_experience.py index aa58584..8b8ef1a 100644 --- a/app/application/use_cases/work_experience/delete_experience.py +++ b/app/application/use_cases/work_experience/delete_experience.py @@ -4,31 +4,34 @@ Deletes an existing work experience. """ +from typing import TYPE_CHECKING + +from app.application.dto import DeleteExperienceRequest, SuccessResponse from app.shared.interfaces import ICommandUseCase, IOrderedRepository from app.shared.shared_exceptions import NotFoundException -from app.application.dto import DeleteExperienceRequest, SuccessResponse -from typing import TYPE_CHECKING if TYPE_CHECKING: from app.domain.entities import WorkExperience as WorkExperienceType -class DeleteExperienceUseCase(ICommandUseCase[DeleteExperienceRequest, SuccessResponse]): +class DeleteExperienceUseCase( + ICommandUseCase[DeleteExperienceRequest, SuccessResponse] +): """ Use case for deleting a work experience. - + Business Rules: - Experience must exist - Deletion is permanent - + Dependencies: - IOrderedRepository[WorkExperience]: For experience data access """ - def __init__(self, experience_repository: IOrderedRepository['WorkExperienceType']): + def __init__(self, experience_repository: IOrderedRepository["WorkExperienceType"]): """ Initialize use case with dependencies. - + Args: experience_repository: Work experience repository interface """ @@ -37,20 +40,20 @@ def __init__(self, experience_repository: IOrderedRepository['WorkExperienceType async def execute(self, request: DeleteExperienceRequest) -> SuccessResponse: """ Execute the use case. - + Args: request: Delete experience request with experience ID - + Returns: SuccessResponse confirming deletion - + Raises: NotFoundException: If experience doesn't exist """ # Attempt to delete deleted = await self.experience_repo.delete(request.experience_id) - + if not deleted: raise NotFoundException("WorkExperience", request.experience_id) - - return SuccessResponse(message="Work experience deleted successfully") \ No newline at end of file + + return SuccessResponse(message="Work experience deleted successfully") diff --git a/app/application/use_cases/work_experience/edit_experience.py b/app/application/use_cases/work_experience/edit_experience.py index bd8d762..d8b6067 100644 --- a/app/application/use_cases/work_experience/edit_experience.py +++ b/app/application/use_cases/work_experience/edit_experience.py @@ -4,32 +4,35 @@ Updates an existing work experience. """ +from typing import TYPE_CHECKING + +from app.application.dto import EditExperienceRequest, WorkExperienceResponse from app.shared.interfaces import ICommandUseCase, IOrderedRepository from app.shared.shared_exceptions import NotFoundException -from app.application.dto import EditExperienceRequest, WorkExperienceResponse -from typing import TYPE_CHECKING if TYPE_CHECKING: from app.domain.entities import WorkExperience as WorkExperienceType -class EditExperienceUseCase(ICommandUseCase[EditExperienceRequest, WorkExperienceResponse]): +class EditExperienceUseCase( + ICommandUseCase[EditExperienceRequest, WorkExperienceResponse] +): """ Use case for editing a work experience. - + Business Rules: - Experience must exist - Only provided fields are updated - Validations are performed by the entity - + Dependencies: - IOrderedRepository[WorkExperience]: For experience data access """ - def __init__(self, experience_repository: IOrderedRepository['WorkExperienceType']): + def __init__(self, experience_repository: IOrderedRepository["WorkExperienceType"]): """ Initialize use case with dependencies. - + Args: experience_repository: Work experience repository interface """ @@ -38,23 +41,23 @@ def __init__(self, experience_repository: IOrderedRepository['WorkExperienceType async def execute(self, request: EditExperienceRequest) -> WorkExperienceResponse: """ Execute the use case. - + Args: request: Edit experience request with fields to update - + Returns: WorkExperienceResponse with updated experience data - + Raises: NotFoundException: If experience doesn't exist DomainError: If validation fails """ # Get existing experience experience = await self.experience_repo.get_by_id(request.experience_id) - + if not experience: raise NotFoundException("WorkExperience", request.experience_id) - + # Update info (entity validates) experience.update_info( role=request.role, @@ -63,13 +66,13 @@ async def execute(self, request: EditExperienceRequest) -> WorkExperienceRespons start_date=request.start_date, end_date=request.end_date, ) - + # Update responsibilities if provided if request.responsibilities is not None: experience.update_responsibilities(request.responsibilities) - + # Persist changes updated_experience = await self.experience_repo.update(experience) - + # Convert to DTO and return - return WorkExperienceResponse.from_entity(updated_experience) \ No newline at end of file + return WorkExperienceResponse.from_entity(updated_experience) diff --git a/app/application/use_cases/work_experience/list_experiences.py b/app/application/use_cases/work_experience/list_experiences.py index bcaf9fc..a6aae37 100644 --- a/app/application/use_cases/work_experience/list_experiences.py +++ b/app/application/use_cases/work_experience/list_experiences.py @@ -4,50 +4,54 @@ Retrieves all work experiences for a profile, ordered by orderIndex. """ -from app.shared.interfaces import IQueryUseCase, IOrderedRepository -from app.application.dto import ListExperiencesRequest, WorkExperienceListResponse from typing import TYPE_CHECKING +from app.application.dto import ListExperiencesRequest, WorkExperienceListResponse +from app.shared.interfaces import IOrderedRepository, IQueryUseCase + if TYPE_CHECKING: from app.domain.entities import WorkExperience as WorkExperienceType -class ListExperiencesUseCase(IQueryUseCase[ListExperiencesRequest, WorkExperienceListResponse]): +class ListExperiencesUseCase( + IQueryUseCase[ListExperiencesRequest, WorkExperienceListResponse] +): """ Use case for listing all work experiences. - + Business Rules: - Returns all experiences for the profile - Ordered by orderIndex (configurable direction) - + Dependencies: - IOrderedRepository[WorkExperience]: For experience data access """ - def __init__(self, experience_repository: IOrderedRepository['WorkExperienceType']): + def __init__(self, experience_repository: IOrderedRepository["WorkExperienceType"]): """ Initialize use case with dependencies. - + Args: experience_repository: Work experience repository interface """ self.experience_repo = experience_repository - async def execute(self, request: ListExperiencesRequest) -> WorkExperienceListResponse: + async def execute( + self, request: ListExperiencesRequest + ) -> WorkExperienceListResponse: """ Execute the use case. - + Args: request: List experiences request with profile ID and sort order - + Returns: WorkExperienceListResponse with list of experiences """ # Get all experiences ordered by orderIndex experiences = await self.experience_repo.get_all_ordered( - profile_id=request.profile_id, - ascending=request.ascending + profile_id=request.profile_id, ascending=request.ascending ) - + # Convert to DTO and return - return WorkExperienceListResponse.from_entities(experiences) \ No newline at end of file + return WorkExperienceListResponse.from_entities(experiences) diff --git a/app/config/settings.py b/app/config/settings.py index 2f35ba2..9edd9cf 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -1,31 +1,33 @@ -from pydantic_settings import BaseSettings, SettingsConfigDict -from pydantic import Field -from typing import List from functools import lru_cache +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + class Settings(BaseSettings): """ Configuración centralizada de la aplicación. Las variables se cargan automáticamente desde archivos .env """ - + # Environment - ENVIRONMENT: str = Field(default="development", description="Environment: development, test, production") - + ENVIRONMENT: str = Field( + default="development", description="Environment: development, test, production" + ) + # Server HOST: str = Field(default="0.0.0.0", alias="API_HOST") PORT: int = Field(default=8000, alias="API_PORT") - + @property def DEBUG(self) -> bool: """Debug mode enabled for development and test environments""" return self.ENVIRONMENT in ["development", "test"] - + # MongoDB MONGODB_URL: str = Field(default="mongodb://mongodb:27017") MONGODB_DB_NAME: str = Field(default="portfolio_db", alias="DATABASE_NAME") - + # CORS CORS_ORIGINS: str = "http://localhost:4321,http://localhost:3000" CORS_CREDENTIALS: bool = True @@ -33,36 +35,36 @@ def DEBUG(self) -> bool: CORS_HEADERS: str = "*" @property - def cors_origins_list(self) -> List[str]: + def cors_origins_list(self) -> list[str]: return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] @property - def cors_methods_list(self) -> List[str]: + def cors_methods_list(self) -> list[str]: return [method.strip() for method in self.CORS_METHODS.split(",")] @property - def cors_headers_list(self) -> List[str]: + def cors_headers_list(self) -> list[str]: return [header.strip() for header in self.CORS_HEADERS.split(",")] # API API_V1_PREFIX: str = "/api/v1" PROJECT_NAME: str = Field(default="AZFE Portfolio API", alias="api_title") VERSION: str = Field(default="1.0.0", alias="api_version") - + # Security SECRET_KEY: str = "your-secret-key-here-change-in-production" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - + model_config = SettingsConfigDict( env_file=".env.development", env_file_encoding="utf-8", case_sensitive=True, - extra="ignore" # Ignora variables extra sin fallar + extra="ignore", # Ignora variables extra sin fallar ) -@lru_cache() +@lru_cache def get_settings() -> Settings: """ Obtiene la configuración (cached). @@ -72,4 +74,4 @@ def get_settings() -> Settings: # Instancia global de settings -settings = get_settings() \ No newline at end of file +settings = get_settings() diff --git a/app/domain/__init__.py b/app/domain/__init__.py index c1d39c8..08c4311 100644 --- a/app/domain/__init__.py +++ b/app/domain/__init__.py @@ -15,51 +15,51 @@ # Export entities for easy importing from .entities import ( - Profile, - WorkExperience, - Skill, - Education, - Project, - Certification, AdditionalTraining, + Certification, ContactInformation, ContactMessage, + Education, + Profile, + Project, + Skill, SocialNetwork, Tool, -) - -# Export value objects -from .value_objects import ( - DateRange, - Email, - Phone, - SkillLevel, - SkillLevelEnum, - ContactInfo, + WorkExperience, ) # Export exceptions from .exceptions import ( DomainError, - InvalidEmailError, - InvalidPhoneError, - InvalidURLError, - InvalidDateRangeError, - InvalidOrderIndexError, - InvalidSkillLevelError, - EmptyFieldError, - InvalidLengthError, DuplicateValueError, - InvalidTitleError, - InvalidNameError, - InvalidDescriptionError, - InvalidRoleError, + EmptyFieldError, + InvalidCategoryError, InvalidCompanyError, + InvalidDateRangeError, + InvalidDescriptionError, + InvalidEmailError, InvalidInstitutionError, InvalidIssuerError, - InvalidProviderError, - InvalidCategoryError, + InvalidLengthError, + InvalidNameError, + InvalidOrderIndexError, + InvalidPhoneError, InvalidPlatformError, + InvalidProviderError, + InvalidRoleError, + InvalidSkillLevelError, + InvalidTitleError, + InvalidURLError, +) + +# Export value objects +from .value_objects import ( + ContactInfo, + DateRange, + Email, + Phone, + SkillLevel, + SkillLevelEnum, ) __all__ = [ @@ -105,4 +105,4 @@ "InvalidPlatformError", ] -__version__ = "0.1.0" \ No newline at end of file +__version__ = "0.1.0" diff --git a/app/domain/entities/additional_training.py b/app/domain/entities/additional_training.py index 12c74aa..f19e857 100644 --- a/app/domain/entities/additional_training.py +++ b/app/domain/entities/additional_training.py @@ -13,19 +13,18 @@ - RB-AT07: orderIndex is required and must be unique per profile """ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Optional import re import uuid +from dataclasses import dataclass, field +from datetime import datetime from ..exceptions import ( EmptyFieldError, InvalidLengthError, - InvalidURLError, - InvalidTitleError, - InvalidProviderError, InvalidOrderIndexError, + InvalidProviderError, + InvalidTitleError, + InvalidURLError, ) @@ -33,7 +32,7 @@ class AdditionalTraining: """ AdditionalTraining entity representing courses, workshops, or other training. - + This entity represents non-formal education and professional development. """ @@ -43,9 +42,9 @@ class AdditionalTraining: provider: str completion_date: datetime order_index: int - duration: Optional[str] = None - certificate_url: Optional[str] = None - description: Optional[str] = None + duration: str | None = None + certificate_url: str | None = None + description: str | None = None created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -55,12 +54,13 @@ class AdditionalTraining: MAX_DURATION_LENGTH = 50 MAX_DESCRIPTION_LENGTH = 500 URL_PATTERN = re.compile( - r'^https?://' - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' - r'localhost|' - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' - r'(?::\d+)?' - r'(?:/?|[/?]\S+)$', re.IGNORECASE + r"^https?://" + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|" + r"localhost|" + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" + r"(?::\d+)?" + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, ) def __post_init__(self): @@ -80,13 +80,13 @@ def create( provider: str, completion_date: datetime, order_index: int, - duration: Optional[str] = None, - certificate_url: Optional[str] = None, - description: Optional[str] = None, + duration: str | None = None, + certificate_url: str | None = None, + description: str | None = None, ) -> "AdditionalTraining": """ Factory method to create a new AdditionalTraining. - + Args: profile_id: Reference to the Profile title: Training/course title @@ -96,7 +96,7 @@ def create( duration: Training duration (e.g., "40 hours") certificate_url: URL to certificate description: Optional description - + Returns: A new AdditionalTraining instance with generated UUID """ @@ -114,16 +114,16 @@ def create( def update_info( self, - title: Optional[str] = None, - provider: Optional[str] = None, - completion_date: Optional[datetime] = None, - duration: Optional[str] = None, - certificate_url: Optional[str] = None, - description: Optional[str] = None, + title: str | None = None, + provider: str | None = None, + completion_date: datetime | None = None, + duration: str | None = None, + certificate_url: str | None = None, + description: str | None = None, ) -> None: """ Update training information. - + Args: title: New title (optional) provider: New provider (optional) @@ -135,32 +135,32 @@ def update_info( if title is not None: self.title = title self._validate_title() - + if provider is not None: self.provider = provider self._validate_provider() - + if completion_date is not None: self.completion_date = completion_date - + if duration is not None: self.duration = duration self._validate_duration() - + if certificate_url is not None: self.certificate_url = certificate_url self._validate_certificate_url() - + if description is not None: self.description = description self._validate_description() - + self._mark_as_updated() def update_order(self, new_order_index: int) -> None: """ Update the order index. - + Args: new_order_index: New position in the list """ @@ -177,7 +177,7 @@ def _validate_title(self) -> None: """Validate title field according to business rules.""" if not self.title or not self.title.strip(): raise InvalidTitleError("Title cannot be empty") - + if len(self.title) > self.MAX_TITLE_LENGTH: raise InvalidLengthError("title", max_length=self.MAX_TITLE_LENGTH) @@ -185,7 +185,7 @@ def _validate_provider(self) -> None: """Validate provider field according to business rules.""" if not self.provider or not self.provider.strip(): raise InvalidProviderError("Provider cannot be empty") - + if len(self.provider) > self.MAX_PROVIDER_LENGTH: raise InvalidLengthError("provider", max_length=self.MAX_PROVIDER_LENGTH) @@ -195,7 +195,9 @@ def _validate_duration(self) -> None: if self.duration.strip() == "": self.duration = None elif len(self.duration) > self.MAX_DURATION_LENGTH: - raise InvalidLengthError("duration", max_length=self.MAX_DURATION_LENGTH) + raise InvalidLengthError( + "duration", max_length=self.MAX_DURATION_LENGTH + ) def _validate_certificate_url(self) -> None: """Validate certificate URL format.""" @@ -211,7 +213,9 @@ def _validate_description(self) -> None: if self.description.strip() == "": self.description = None elif len(self.description) > self.MAX_DESCRIPTION_LENGTH: - raise InvalidLengthError("description", max_length=self.MAX_DESCRIPTION_LENGTH) + raise InvalidLengthError( + "description", max_length=self.MAX_DESCRIPTION_LENGTH + ) def _validate_order_index(self) -> None: """Validate order index.""" @@ -224,4 +228,4 @@ def _mark_as_updated(self) -> None: def __repr__(self) -> str: """String representation for debugging.""" - return f"AdditionalTraining(id={self.id}, title={self.title}, provider={self.provider})" \ No newline at end of file + return f"AdditionalTraining(id={self.id}, title={self.title}, provider={self.provider})" diff --git a/app/domain/entities/certification.py b/app/domain/entities/certification.py index fed9dcc..41f3da6 100644 --- a/app/domain/entities/certification.py +++ b/app/domain/entities/certification.py @@ -13,20 +13,19 @@ - RB-C07: orderIndex is required and must be unique per profile """ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Optional import re import uuid +from dataclasses import dataclass, field +from datetime import datetime from ..exceptions import ( EmptyFieldError, - InvalidLengthError, InvalidDateRangeError, - InvalidURLError, - InvalidTitleError, InvalidIssuerError, + InvalidLengthError, InvalidOrderIndexError, + InvalidTitleError, + InvalidURLError, ) @@ -34,7 +33,7 @@ class Certification: """ Certification entity representing a professional certification. - + This entity maintains temporal coherence and credential validation. """ @@ -44,9 +43,9 @@ class Certification: issuer: str issue_date: datetime order_index: int - expiry_date: Optional[datetime] = None - credential_id: Optional[str] = None - credential_url: Optional[str] = None + expiry_date: datetime | None = None + credential_id: str | None = None + credential_url: str | None = None created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -55,12 +54,13 @@ class Certification: MAX_ISSUER_LENGTH = 100 MAX_CREDENTIAL_ID_LENGTH = 100 URL_PATTERN = re.compile( - r'^https?://' - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' - r'localhost|' - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' - r'(?::\d+)?' - r'(?:/?|[/?]\S+)$', re.IGNORECASE + r"^https?://" + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|" + r"localhost|" + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" + r"(?::\d+)?" + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, ) def __post_init__(self): @@ -80,13 +80,13 @@ def create( issuer: str, issue_date: datetime, order_index: int, - expiry_date: Optional[datetime] = None, - credential_id: Optional[str] = None, - credential_url: Optional[str] = None, + expiry_date: datetime | None = None, + credential_id: str | None = None, + credential_url: str | None = None, ) -> "Certification": """ Factory method to create a new Certification. - + Args: profile_id: Reference to the Profile title: Certification title @@ -96,7 +96,7 @@ def create( expiry_date: When the certification expires (None if no expiry) credential_id: Certification credential ID credential_url: URL to verify the credential - + Returns: A new Certification instance with generated UUID """ @@ -114,16 +114,16 @@ def create( def update_info( self, - title: Optional[str] = None, - issuer: Optional[str] = None, - issue_date: Optional[datetime] = None, - expiry_date: Optional[datetime] = None, - credential_id: Optional[str] = None, - credential_url: Optional[str] = None, + title: str | None = None, + issuer: str | None = None, + issue_date: datetime | None = None, + expiry_date: datetime | None = None, + credential_id: str | None = None, + credential_url: str | None = None, ) -> None: """ Update certification information. - + Args: title: New title (optional) issuer: New issuer (optional) @@ -135,32 +135,32 @@ def update_info( if title is not None: self.title = title self._validate_title() - + if issuer is not None: self.issuer = issuer self._validate_issuer() - + if issue_date is not None: self.issue_date = issue_date - + if expiry_date is not None: self.expiry_date = expiry_date - + if credential_id is not None: self.credential_id = credential_id self._validate_credential_id() - + if credential_url is not None: self.credential_url = credential_url self._validate_credential_url() - + self._validate_dates() self._mark_as_updated() def update_order(self, new_order_index: int) -> None: """ Update the order index. - + Args: new_order_index: New position in the list """ @@ -187,7 +187,7 @@ def _validate_title(self) -> None: """Validate title field according to business rules.""" if not self.title or not self.title.strip(): raise InvalidTitleError("Title cannot be empty") - + if len(self.title) > self.MAX_TITLE_LENGTH: raise InvalidLengthError("title", max_length=self.MAX_TITLE_LENGTH) @@ -195,17 +195,14 @@ def _validate_issuer(self) -> None: """Validate issuer field according to business rules.""" if not self.issuer or not self.issuer.strip(): raise InvalidIssuerError("Issuer cannot be empty") - + if len(self.issuer) > self.MAX_ISSUER_LENGTH: raise InvalidLengthError("issuer", max_length=self.MAX_ISSUER_LENGTH) def _validate_dates(self) -> None: """Validate date range coherence.""" if self.expiry_date is not None and self.expiry_date <= self.issue_date: - raise InvalidDateRangeError( - str(self.issue_date), - str(self.expiry_date) - ) + raise InvalidDateRangeError(str(self.issue_date), str(self.expiry_date)) def _validate_credential_id(self) -> None: """Validate credential ID field.""" @@ -214,8 +211,7 @@ def _validate_credential_id(self) -> None: self.credential_id = None elif len(self.credential_id) > self.MAX_CREDENTIAL_ID_LENGTH: raise InvalidLengthError( - "credential_id", - max_length=self.MAX_CREDENTIAL_ID_LENGTH + "credential_id", max_length=self.MAX_CREDENTIAL_ID_LENGTH ) def _validate_credential_url(self) -> None: @@ -237,4 +233,4 @@ def _mark_as_updated(self) -> None: def __repr__(self) -> str: """String representation for debugging.""" - return f"Certification(id={self.id}, title={self.title}, issuer={self.issuer})" \ No newline at end of file + return f"Certification(id={self.id}, title={self.title}, issuer={self.issuer})" diff --git a/app/domain/entities/contact_information.py b/app/domain/entities/contact_information.py index 4eaa836..f587c3d 100644 --- a/app/domain/entities/contact_information.py +++ b/app/domain/entities/contact_information.py @@ -12,11 +12,10 @@ - RB-CI06: Only one ContactInformation per Profile """ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Optional import re import uuid +from dataclasses import dataclass, field +from datetime import datetime from ..exceptions import ( EmptyFieldError, @@ -30,7 +29,7 @@ class ContactInformation: """ ContactInformation entity representing official contact details. - + This is a value-rich entity that maintains format validation for all contact methods. Only one ContactInformation instance should exist per Profile. """ @@ -38,27 +37,26 @@ class ContactInformation: id: str profile_id: str email: str - phone: Optional[str] = None - linkedin: Optional[str] = None - github: Optional[str] = None - website: Optional[str] = None + phone: str | None = None + linkedin: str | None = None + github: str | None = None + website: str | None = None created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) # Validation patterns - EMAIL_PATTERN = re.compile( - r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - ) + EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") PHONE_PATTERN = re.compile( - r'^\+?[1-9]\d{1,14}$' # E.164 format (international phone numbers) + r"^\+?[1-9]\d{1,14}$" # E.164 format (international phone numbers) ) URL_PATTERN = re.compile( - r'^https?://' - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' - r'localhost|' - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' - r'(?::\d+)?' - r'(?:/?|[/?]\S+)$', re.IGNORECASE + r"^https?://" + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|" + r"localhost|" + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" + r"(?::\d+)?" + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, ) def __post_init__(self): @@ -74,14 +72,14 @@ def __post_init__(self): def create( profile_id: str, email: str, - phone: Optional[str] = None, - linkedin: Optional[str] = None, - github: Optional[str] = None, - website: Optional[str] = None, + phone: str | None = None, + linkedin: str | None = None, + github: str | None = None, + website: str | None = None, ) -> "ContactInformation": """ Factory method to create new ContactInformation. - + Args: profile_id: Reference to the Profile email: Primary email address @@ -89,7 +87,7 @@ def create( linkedin: LinkedIn profile URL (optional) github: GitHub profile URL (optional) website: Personal website URL (optional) - + Returns: A new ContactInformation instance with generated UUID """ @@ -106,7 +104,7 @@ def create( def update_email(self, email: str) -> None: """ Update email address. - + Args: email: New email address """ @@ -114,10 +112,10 @@ def update_email(self, email: str) -> None: self._validate_email() self._mark_as_updated() - def update_phone(self, phone: Optional[str]) -> None: + def update_phone(self, phone: str | None) -> None: """ Update phone number. - + Args: phone: New phone number or None to remove """ @@ -127,13 +125,13 @@ def update_phone(self, phone: Optional[str]) -> None: def update_social_links( self, - linkedin: Optional[str] = None, - github: Optional[str] = None, - website: Optional[str] = None, + linkedin: str | None = None, + github: str | None = None, + website: str | None = None, ) -> None: """ Update social and web links. - + Args: linkedin: LinkedIn URL (optional) github: GitHub URL (optional) @@ -142,15 +140,15 @@ def update_social_links( if linkedin is not None: self.linkedin = linkedin self._validate_linkedin() - + if github is not None: self.github = github self._validate_github() - + if website is not None: self.website = website self._validate_website() - + self._mark_as_updated() def _validate_profile_id(self) -> None: @@ -162,7 +160,7 @@ def _validate_email(self) -> None: """Validate email format.""" if not self.email or not self.email.strip(): raise EmptyFieldError("email") - + if not self.EMAIL_PATTERN.match(self.email): raise InvalidEmailError(self.email) @@ -173,7 +171,12 @@ def _validate_phone(self) -> None: self.phone = None else: # Remove common separators for validation - phone_digits = self.phone.replace(" ", "").replace("-", "").replace("(", "").replace(")", "") + phone_digits = ( + self.phone.replace(" ", "") + .replace("-", "") + .replace("(", "") + .replace(")", "") + ) if not self.PHONE_PATTERN.match(phone_digits): raise InvalidPhoneError(self.phone) @@ -207,4 +210,4 @@ def _mark_as_updated(self) -> None: def __repr__(self) -> str: """String representation for debugging.""" - return f"ContactInformation(id={self.id}, email={self.email})" \ No newline at end of file + return f"ContactInformation(id={self.id}, email={self.email})" diff --git a/app/domain/entities/contact_message.py b/app/domain/entities/contact_message.py index 373566c..488e158 100644 --- a/app/domain/entities/contact_message.py +++ b/app/domain/entities/contact_message.py @@ -12,20 +12,18 @@ - RB-CM06: Messages are append-only (no updates after creation) """ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Optional import re import uuid +from dataclasses import dataclass, field +from datetime import datetime from ..exceptions import ( EmptyFieldError, - InvalidLengthError, InvalidEmailError, + InvalidLengthError, InvalidNameError, ) - # Valid message statuses VALID_MESSAGE_STATUSES = {"pending", "read", "replied"} @@ -34,7 +32,7 @@ class ContactMessage: """ ContactMessage entity representing an inquiry from a visitor. - + This is an append-only entity - messages should not be modified after creation. """ @@ -44,16 +42,14 @@ class ContactMessage: message: str created_at: datetime = field(default_factory=datetime.utcnow) status: str = "pending" - read_at: Optional[datetime] = None - replied_at: Optional[datetime] = None + read_at: datetime | None = None + replied_at: datetime | None = None # Constants MAX_NAME_LENGTH = 100 MIN_MESSAGE_LENGTH = 10 MAX_MESSAGE_LENGTH = 2000 - EMAIL_PATTERN = re.compile( - r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - ) + EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") def __post_init__(self): """Validate entity invariants after initialization.""" @@ -70,12 +66,12 @@ def create( ) -> "ContactMessage": """ Factory method to create a new ContactMessage. - + Args: name: Sender's name email: Sender's email message: Message content - + Returns: A new ContactMessage instance with generated UUID and pending status """ @@ -117,7 +113,7 @@ def _validate_name(self) -> None: """Validate name field according to business rules.""" if not self.name or not self.name.strip(): raise InvalidNameError("Name cannot be empty") - + if len(self.name) > self.MAX_NAME_LENGTH: raise InvalidLengthError("name", max_length=self.MAX_NAME_LENGTH) @@ -125,7 +121,7 @@ def _validate_email(self) -> None: """Validate email format.""" if not self.email or not self.email.strip(): raise EmptyFieldError("email") - + if not self.EMAIL_PATTERN.match(self.email): raise InvalidEmailError(self.email) @@ -133,27 +129,22 @@ def _validate_message(self) -> None: """Validate message field according to business rules.""" if not self.message or not self.message.strip(): raise EmptyFieldError("message") - + if len(self.message) < self.MIN_MESSAGE_LENGTH: - raise InvalidLengthError( - "message", - min_length=self.MIN_MESSAGE_LENGTH - ) - + raise InvalidLengthError("message", min_length=self.MIN_MESSAGE_LENGTH) + if len(self.message) > self.MAX_MESSAGE_LENGTH: - raise InvalidLengthError( - "message", - max_length=self.MAX_MESSAGE_LENGTH - ) + raise InvalidLengthError("message", max_length=self.MAX_MESSAGE_LENGTH) def _validate_status(self) -> None: """Validate status field.""" if self.status not in VALID_MESSAGE_STATUSES: from ..exceptions import DomainError + raise DomainError( f"Invalid status: '{self.status}'. Must be one of: {', '.join(VALID_MESSAGE_STATUSES)}" ) def __repr__(self) -> str: """String representation for debugging.""" - return f"ContactMessage(id={self.id}, from={self.name}, status={self.status})" \ No newline at end of file + return f"ContactMessage(id={self.id}, from={self.name}, status={self.status})" diff --git a/app/domain/entities/education.py b/app/domain/entities/education.py index 98affbd..1f159b4 100644 --- a/app/domain/entities/education.py +++ b/app/domain/entities/education.py @@ -14,9 +14,9 @@ """ import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass +from dataclasses import field as dataclass_field from datetime import datetime -from typing import Optional from ..exceptions import ( EmptyFieldError, @@ -42,10 +42,10 @@ class Education: field: str start_date: datetime order_index: int - description: Optional[str] = None - end_date: Optional[datetime] = None - created_at: datetime = field(default_factory=datetime.utcnow) - updated_at: datetime = field(default_factory=datetime.utcnow) + description: str | None = None + end_date: datetime | None = None + created_at: datetime = dataclass_field(default_factory=datetime.utcnow) + updated_at: datetime = dataclass_field(default_factory=datetime.utcnow) # Constants MAX_INSTITUTION_LENGTH = 100 @@ -71,8 +71,8 @@ def create( field: str, start_date: datetime, order_index: int, - description: Optional[str] = None, - end_date: Optional[datetime] = None, + description: str | None = None, + end_date: datetime | None = None, ) -> "Education": """ Factory method to create a new Education entry. @@ -104,12 +104,12 @@ def create( def update_info( self, - institution: Optional[str] = None, - degree: Optional[str] = None, - field: Optional[str] = None, - description: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, + institution: str | None = None, + degree: str | None = None, + field: str | None = None, + description: str | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, ) -> None: """ Update education information. diff --git a/app/domain/entities/profile.py b/app/domain/entities/profile.py index a8338bc..f789c3d 100644 --- a/app/domain/entities/profile.py +++ b/app/domain/entities/profile.py @@ -16,13 +16,8 @@ import uuid from dataclasses import dataclass, field from datetime import datetime -from typing import Optional -from ..exceptions import ( - EmptyFieldError, - InvalidLengthError, - InvalidURLError, -) +from ..exceptions import EmptyFieldError, InvalidLengthError, InvalidURLError @dataclass @@ -37,9 +32,9 @@ class Profile: id: str name: str headline: str - bio: Optional[str] = None - location: Optional[str] = None - avatar_url: Optional[str] = None + bio: str | None = None + location: str | None = None + avatar_url: str | None = None created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -70,9 +65,9 @@ def __post_init__(self): def create( name: str, headline: str, - bio: Optional[str] = None, - location: Optional[str] = None, - avatar_url: Optional[str] = None, + bio: str | None = None, + location: str | None = None, + avatar_url: str | None = None, ) -> "Profile": """ Factory method to create a new Profile. @@ -98,10 +93,10 @@ def create( def update_basic_info( self, - name: Optional[str] = None, - headline: Optional[str] = None, - bio: Optional[str] = None, - location: Optional[str] = None, + name: str | None = None, + headline: str | None = None, + bio: str | None = None, + location: str | None = None, ) -> None: """ Update basic profile information. @@ -130,7 +125,7 @@ def update_basic_info( self._mark_as_updated() - def update_avatar(self, avatar_url: Optional[str]) -> None: + def update_avatar(self, avatar_url: str | None) -> None: """ Update profile avatar URL. diff --git a/app/domain/entities/project.py b/app/domain/entities/project.py index 949c94e..40b728f 100644 --- a/app/domain/entities/project.py +++ b/app/domain/entities/project.py @@ -15,20 +15,19 @@ - RB-PR09: If no URLs, description must be sufficiently detailed (min 100 chars) """ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Optional, List import re import uuid +from dataclasses import dataclass, field +from datetime import datetime from ..exceptions import ( EmptyFieldError, - InvalidLengthError, InvalidDateRangeError, - InvalidURLError, - InvalidTitleError, InvalidDescriptionError, + InvalidLengthError, InvalidOrderIndexError, + InvalidTitleError, + InvalidURLError, ) @@ -36,7 +35,7 @@ class Project: """ Project entity representing a professional project. - + This entity maintains temporal coherence, URL validation, and ordering. """ @@ -46,10 +45,10 @@ class Project: description: str start_date: datetime order_index: int - end_date: Optional[datetime] = None - live_url: Optional[str] = None - repo_url: Optional[str] = None - technologies: List[str] = field(default_factory=list) + end_date: datetime | None = None + live_url: str | None = None + repo_url: str | None = None + technologies: list[str] = field(default_factory=list) created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -61,12 +60,13 @@ class Project: MAX_TECHNOLOGIES = 20 MAX_TECHNOLOGY_LENGTH = 50 URL_PATTERN = re.compile( - r'^https?://' - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' - r'localhost|' - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' - r'(?::\d+)?' - r'(?:/?|[/?]\S+)$', re.IGNORECASE + r"^https?://" + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|" + r"localhost|" + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" + r"(?::\d+)?" + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, ) def __post_init__(self): @@ -87,14 +87,14 @@ def create( description: str, start_date: datetime, order_index: int, - end_date: Optional[datetime] = None, - live_url: Optional[str] = None, - repo_url: Optional[str] = None, - technologies: Optional[List[str]] = None, + end_date: datetime | None = None, + live_url: str | None = None, + repo_url: str | None = None, + technologies: list[str] | None = None, ) -> "Project": """ Factory method to create a new Project. - + Args: profile_id: Reference to the Profile title: Project title @@ -105,7 +105,7 @@ def create( live_url: URL to live project repo_url: URL to repository technologies: List of technologies used - + Returns: A new Project instance with generated UUID """ @@ -124,14 +124,14 @@ def create( def update_info( self, - title: Optional[str] = None, - description: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, + title: str | None = None, + description: str | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, ) -> None: """ Update project basic information. - + Args: title: New title (optional) description: New description (optional) @@ -141,47 +141,47 @@ def update_info( if title is not None: self.title = title self._validate_title() - + if description is not None: self.description = description self._validate_description() - + if start_date is not None: self.start_date = start_date - + if end_date is not None: self.end_date = end_date - + self._validate_dates() self._validate_description_sufficiency() self._mark_as_updated() def update_urls( self, - live_url: Optional[str] = None, - repo_url: Optional[str] = None, + live_url: str | None = None, + repo_url: str | None = None, ) -> None: """ Update project URLs. - + Args: live_url: New live URL (optional) repo_url: New repository URL (optional) """ if live_url is not None: self.live_url = live_url - + if repo_url is not None: self.repo_url = repo_url - + self._validate_urls() self._validate_description_sufficiency() self._mark_as_updated() - def update_technologies(self, technologies: List[str]) -> None: + def update_technologies(self, technologies: list[str]) -> None: """ Update technologies list. - + Args: technologies: New list of technologies """ @@ -192,26 +192,28 @@ def update_technologies(self, technologies: List[str]) -> None: def add_technology(self, technology: str) -> None: """ Add a single technology. - + Args: technology: Technology name to add """ if len(self.technologies) >= self.MAX_TECHNOLOGIES: raise InvalidLengthError("technologies", max_length=self.MAX_TECHNOLOGIES) - + if not technology or not technology.strip(): raise EmptyFieldError("technology") - + if len(technology) > self.MAX_TECHNOLOGY_LENGTH: - raise InvalidLengthError("technology", max_length=self.MAX_TECHNOLOGY_LENGTH) - + raise InvalidLengthError( + "technology", max_length=self.MAX_TECHNOLOGY_LENGTH + ) + self.technologies.append(technology) self._mark_as_updated() def update_order(self, new_order_index: int) -> None: """ Update the order index. - + Args: new_order_index: New position in the list """ @@ -236,7 +238,7 @@ def _validate_title(self) -> None: """Validate title field according to business rules.""" if not self.title or not self.title.strip(): raise InvalidTitleError("Title cannot be empty") - + if len(self.title) > self.MAX_TITLE_LENGTH: raise InvalidLengthError("title", max_length=self.MAX_TITLE_LENGTH) @@ -244,17 +246,15 @@ def _validate_description(self) -> None: """Validate description field according to business rules.""" if not self.description or not self.description.strip(): raise InvalidDescriptionError("Description cannot be empty") - + if len(self.description) < self.MIN_DESCRIPTION_LENGTH: raise InvalidLengthError( - "description", - min_length=self.MIN_DESCRIPTION_LENGTH + "description", min_length=self.MIN_DESCRIPTION_LENGTH ) - + if len(self.description) > self.MAX_DESCRIPTION_LENGTH: raise InvalidLengthError( - "description", - max_length=self.MAX_DESCRIPTION_LENGTH + "description", max_length=self.MAX_DESCRIPTION_LENGTH ) def _validate_description_sufficiency(self) -> None: @@ -262,7 +262,10 @@ def _validate_description_sufficiency(self) -> None: Validate that description is sufficiently detailed if no URLs provided. Business rule: RB-PR09 """ - if not self.has_urls() and len(self.description) < self.MIN_DESCRIPTION_WITHOUT_URLS: + if ( + not self.has_urls() + and len(self.description) < self.MIN_DESCRIPTION_WITHOUT_URLS + ): raise InvalidDescriptionError( f"Description must be at least {self.MIN_DESCRIPTION_WITHOUT_URLS} " f"characters when no URLs are provided" @@ -271,10 +274,7 @@ def _validate_description_sufficiency(self) -> None: def _validate_dates(self) -> None: """Validate date range coherence.""" if self.end_date is not None and self.end_date <= self.start_date: - raise InvalidDateRangeError( - str(self.start_date), - str(self.end_date) - ) + raise InvalidDateRangeError(str(self.start_date), str(self.end_date)) def _validate_urls(self) -> None: """Validate URL formats.""" @@ -283,7 +283,7 @@ def _validate_urls(self) -> None: self.live_url = None elif not self.URL_PATTERN.match(self.live_url): raise InvalidURLError(self.live_url) - + if self.repo_url is not None: if self.repo_url.strip() == "": self.repo_url = None @@ -294,14 +294,13 @@ def _validate_technologies(self) -> None: """Validate technologies list.""" if len(self.technologies) > self.MAX_TECHNOLOGIES: raise InvalidLengthError("technologies", max_length=self.MAX_TECHNOLOGIES) - + for tech in self.technologies: if not tech or not tech.strip(): raise EmptyFieldError("technology item") if len(tech) > self.MAX_TECHNOLOGY_LENGTH: raise InvalidLengthError( - "technology item", - max_length=self.MAX_TECHNOLOGY_LENGTH + "technology item", max_length=self.MAX_TECHNOLOGY_LENGTH ) def _validate_order_index(self) -> None: @@ -315,4 +314,4 @@ def _mark_as_updated(self) -> None: def __repr__(self) -> str: """String representation for debugging.""" - return f"Project(id={self.id}, title={self.title})" \ No newline at end of file + return f"Project(id={self.id}, title={self.title})" diff --git a/app/domain/entities/skill.py b/app/domain/entities/skill.py index fa24723..cdb087d 100644 --- a/app/domain/entities/skill.py +++ b/app/domain/entities/skill.py @@ -14,7 +14,6 @@ import uuid from dataclasses import dataclass, field from datetime import datetime -from typing import Optional from ..exceptions import ( EmptyFieldError, @@ -42,7 +41,7 @@ class Skill: name: str category: str order_index: int - level: Optional[str] = None + level: str | None = None created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -64,7 +63,7 @@ def create( name: str, category: str, order_index: int, - level: Optional[str] = None, + level: str | None = None, ) -> "Skill": """ Factory method to create a new Skill. @@ -90,9 +89,9 @@ def create( def update_info( self, - name: Optional[str] = None, - category: Optional[str] = None, - level: Optional[str] = None, + name: str | None = None, + category: str | None = None, + level: str | None = None, ) -> None: """ Update skill information. diff --git a/app/domain/entities/social_network.py b/app/domain/entities/social_network.py index 1ffe448..0cff573 100644 --- a/app/domain/entities/social_network.py +++ b/app/domain/entities/social_network.py @@ -11,18 +11,17 @@ - RB-SN05: orderIndex is required for display ordering """ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Optional import re import uuid +from dataclasses import dataclass, field +from datetime import datetime from ..exceptions import ( EmptyFieldError, InvalidLengthError, - InvalidURLError, - InvalidPlatformError, InvalidOrderIndexError, + InvalidPlatformError, + InvalidURLError, ) @@ -30,7 +29,7 @@ class SocialNetwork: """ SocialNetwork entity representing a social media profile. - + This entity maintains uniqueness by platform and validates URLs. """ @@ -39,7 +38,7 @@ class SocialNetwork: platform: str url: str order_index: int - username: Optional[str] = None + username: str | None = None created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -47,12 +46,13 @@ class SocialNetwork: MAX_PLATFORM_LENGTH = 50 MAX_USERNAME_LENGTH = 100 URL_PATTERN = re.compile( - r'^https?://' - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' - r'localhost|' - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' - r'(?::\d+)?' - r'(?:/?|[/?]\S+)$', re.IGNORECASE + r"^https?://" + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|" + r"localhost|" + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" + r"(?::\d+)?" + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, ) def __post_init__(self): @@ -69,18 +69,18 @@ def create( platform: str, url: str, order_index: int, - username: Optional[str] = None, + username: str | None = None, ) -> "SocialNetwork": """ Factory method to create a new SocialNetwork. - + Args: profile_id: Reference to the Profile platform: Social network platform name (e.g., "LinkedIn", "GitHub") url: Profile URL on the platform order_index: Position in the ordered list username: Username on the platform (optional) - + Returns: A new SocialNetwork instance with generated UUID """ @@ -95,13 +95,13 @@ def create( def update_info( self, - platform: Optional[str] = None, - url: Optional[str] = None, - username: Optional[str] = None, + platform: str | None = None, + url: str | None = None, + username: str | None = None, ) -> None: """ Update social network information. - + Args: platform: New platform name (optional) url: New URL (optional) @@ -110,21 +110,21 @@ def update_info( if platform is not None: self.platform = platform self._validate_platform() - + if url is not None: self.url = url self._validate_url() - + if username is not None: self.username = username self._validate_username() - + self._mark_as_updated() def update_order(self, new_order_index: int) -> None: """ Update the order index. - + Args: new_order_index: New position in the list """ @@ -141,7 +141,7 @@ def _validate_platform(self) -> None: """Validate platform field according to business rules.""" if not self.platform or not self.platform.strip(): raise InvalidPlatformError("Platform cannot be empty") - + if len(self.platform) > self.MAX_PLATFORM_LENGTH: raise InvalidLengthError("platform", max_length=self.MAX_PLATFORM_LENGTH) @@ -149,7 +149,7 @@ def _validate_url(self) -> None: """Validate URL format.""" if not self.url or not self.url.strip(): raise EmptyFieldError("url") - + if not self.URL_PATTERN.match(self.url): raise InvalidURLError(self.url) @@ -159,7 +159,9 @@ def _validate_username(self) -> None: if self.username.strip() == "": self.username = None elif len(self.username) > self.MAX_USERNAME_LENGTH: - raise InvalidLengthError("username", max_length=self.MAX_USERNAME_LENGTH) + raise InvalidLengthError( + "username", max_length=self.MAX_USERNAME_LENGTH + ) def _validate_order_index(self) -> None: """Validate order index.""" @@ -172,4 +174,4 @@ def _mark_as_updated(self) -> None: def __repr__(self) -> str: """String representation for debugging.""" - return f"SocialNetwork(id={self.id}, platform={self.platform})" \ No newline at end of file + return f"SocialNetwork(id={self.id}, platform={self.platform})" diff --git a/app/domain/entities/tool.py b/app/domain/entities/tool.py index 561d8f2..cb64165 100644 --- a/app/domain/entities/tool.py +++ b/app/domain/entities/tool.py @@ -15,7 +15,6 @@ import uuid from dataclasses import dataclass, field from datetime import datetime -from typing import Optional from ..exceptions import ( EmptyFieldError, @@ -40,7 +39,7 @@ class Tool: name: str category: str order_index: int - icon_url: Optional[str] = None + icon_url: str | None = None created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -71,7 +70,7 @@ def create( name: str, category: str, order_index: int, - icon_url: Optional[str] = None, + icon_url: str | None = None, ) -> "Tool": """ Factory method to create a new Tool. @@ -97,9 +96,9 @@ def create( def update_info( self, - name: Optional[str] = None, - category: Optional[str] = None, - icon_url: Optional[str] = None, + name: str | None = None, + category: str | None = None, + icon_url: str | None = None, ) -> None: """ Update tool information. diff --git a/app/domain/entities/work_experience.py b/app/domain/entities/work_experience.py index 56d3da0..97b9245 100644 --- a/app/domain/entities/work_experience.py +++ b/app/domain/entities/work_experience.py @@ -13,18 +13,17 @@ - RB-W07: orderIndex is required and must be unique per profile """ +import uuid from dataclasses import dataclass, field from datetime import datetime -from typing import Optional, List -import uuid from ..exceptions import ( EmptyFieldError, - InvalidLengthError, - InvalidDateRangeError, - InvalidRoleError, InvalidCompanyError, + InvalidDateRangeError, + InvalidLengthError, InvalidOrderIndexError, + InvalidRoleError, ) @@ -32,7 +31,7 @@ class WorkExperience: """ WorkExperience entity representing a professional role. - + This entity maintains temporal coherence and ordering invariants. """ @@ -42,9 +41,9 @@ class WorkExperience: company: str start_date: datetime order_index: int - description: Optional[str] = None - end_date: Optional[datetime] = None - responsibilities: List[str] = field(default_factory=list) + description: str | None = None + end_date: datetime | None = None + responsibilities: list[str] = field(default_factory=list) created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -72,13 +71,13 @@ def create( company: str, start_date: datetime, order_index: int, - description: Optional[str] = None, - end_date: Optional[datetime] = None, - responsibilities: Optional[List[str]] = None, + description: str | None = None, + end_date: datetime | None = None, + responsibilities: list[str] | None = None, ) -> "WorkExperience": """ Factory method to create a new WorkExperience. - + Args: profile_id: Reference to the Profile role: Job title/role @@ -88,7 +87,7 @@ def create( description: Optional job description end_date: When the role ended (None if current) responsibilities: Optional list of responsibilities - + Returns: A new WorkExperience instance with generated UUID """ @@ -106,15 +105,15 @@ def create( def update_info( self, - role: Optional[str] = None, - company: Optional[str] = None, - description: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, + role: str | None = None, + company: str | None = None, + description: str | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, ) -> None: """ Update work experience information. - + Args: role: New role (optional) company: New company (optional) @@ -125,28 +124,28 @@ def update_info( if role is not None: self.role = role self._validate_role() - + if company is not None: self.company = company self._validate_company() - + if description is not None: self.description = description self._validate_description() - + if start_date is not None: self.start_date = start_date - + if end_date is not None: self.end_date = end_date - + self._validate_dates() self._mark_as_updated() - def update_responsibilities(self, responsibilities: List[str]) -> None: + def update_responsibilities(self, responsibilities: list[str]) -> None: """ Update responsibilities list. - + Args: responsibilities: New list of responsibilities """ @@ -157,32 +156,30 @@ def update_responsibilities(self, responsibilities: List[str]) -> None: def add_responsibility(self, responsibility: str) -> None: """ Add a single responsibility. - + Args: responsibility: Responsibility text to add """ if len(self.responsibilities) >= self.MAX_RESPONSIBILITIES: raise InvalidLengthError( - "responsibilities", - max_length=self.MAX_RESPONSIBILITIES + "responsibilities", max_length=self.MAX_RESPONSIBILITIES ) - + if not responsibility or not responsibility.strip(): raise EmptyFieldError("responsibility") - + if len(responsibility) > self.MAX_RESPONSIBILITY_LENGTH: raise InvalidLengthError( - "responsibility", - max_length=self.MAX_RESPONSIBILITY_LENGTH + "responsibility", max_length=self.MAX_RESPONSIBILITY_LENGTH ) - + self.responsibilities.append(responsibility) self._mark_as_updated() def update_order(self, new_order_index: int) -> None: """ Update the order index. - + Args: new_order_index: New position in the list """ @@ -203,7 +200,7 @@ def _validate_role(self) -> None: """Validate role field according to business rules.""" if not self.role or not self.role.strip(): raise InvalidRoleError("Role cannot be empty") - + if len(self.role) > self.MAX_ROLE_LENGTH: raise InvalidLengthError("role", max_length=self.MAX_ROLE_LENGTH) @@ -211,7 +208,7 @@ def _validate_company(self) -> None: """Validate company field according to business rules.""" if not self.company or not self.company.strip(): raise InvalidCompanyError("Company name cannot be empty") - + if len(self.company) > self.MAX_COMPANY_LENGTH: raise InvalidLengthError("company", max_length=self.MAX_COMPANY_LENGTH) @@ -221,31 +218,28 @@ def _validate_description(self) -> None: if self.description.strip() == "": self.description = None elif len(self.description) > self.MAX_DESCRIPTION_LENGTH: - raise InvalidLengthError("description", max_length=self.MAX_DESCRIPTION_LENGTH) + raise InvalidLengthError( + "description", max_length=self.MAX_DESCRIPTION_LENGTH + ) def _validate_dates(self) -> None: """Validate date range coherence.""" if self.end_date is not None and self.end_date <= self.start_date: - raise InvalidDateRangeError( - str(self.start_date), - str(self.end_date) - ) + raise InvalidDateRangeError(str(self.start_date), str(self.end_date)) def _validate_responsibilities(self) -> None: """Validate responsibilities list.""" if len(self.responsibilities) > self.MAX_RESPONSIBILITIES: raise InvalidLengthError( - "responsibilities", - max_length=self.MAX_RESPONSIBILITIES + "responsibilities", max_length=self.MAX_RESPONSIBILITIES ) - + for resp in self.responsibilities: if not resp or not resp.strip(): raise EmptyFieldError("responsibility item") if len(resp) > self.MAX_RESPONSIBILITY_LENGTH: raise InvalidLengthError( - "responsibility item", - max_length=self.MAX_RESPONSIBILITY_LENGTH + "responsibility item", max_length=self.MAX_RESPONSIBILITY_LENGTH ) def _validate_order_index(self) -> None: @@ -259,4 +253,4 @@ def _mark_as_updated(self) -> None: def __repr__(self) -> str: """String representation for debugging.""" - return f"WorkExperience(id={self.id}, role={self.role}, company={self.company})" \ No newline at end of file + return f"WorkExperience(id={self.id}, role={self.role}, company={self.company})" diff --git a/app/domain/exceptions/__init__.py b/app/domain/exceptions/__init__.py index d6fde27..00a79f7 100644 --- a/app/domain/exceptions/__init__.py +++ b/app/domain/exceptions/__init__.py @@ -1,24 +1,24 @@ from .domain_errors import ( DomainError, + DuplicateValueError, EmptyFieldError, - InvalidLengthError, - InvalidURLError, - InvalidDateRangeError, - InvalidOrderIndexError, - InvalidNameError, - InvalidTitleError, - InvalidDescriptionError, InvalidCategoryError, - InvalidSkillLevelError, - InvalidRoleError, InvalidCompanyError, - InvalidIssuerError, - InvalidInstitutionError, + InvalidDateRangeError, + InvalidDescriptionError, InvalidEmailError, + InvalidInstitutionError, + InvalidIssuerError, + InvalidLengthError, + InvalidNameError, + InvalidOrderIndexError, InvalidPhoneError, InvalidPlatformError, InvalidProviderError, - DuplicateValueError, + InvalidRoleError, + InvalidSkillLevelError, + InvalidTitleError, + InvalidURLError, ) __all__ = [ diff --git a/app/domain/exceptions/domain_errors.py b/app/domain/exceptions/domain_errors.py index 37d3fd4..bb822eb 100644 --- a/app/domain/exceptions/domain_errors.py +++ b/app/domain/exceptions/domain_errors.py @@ -4,116 +4,142 @@ These represent violations of business rules inside the domain layer. """ + class DomainError(Exception): """Base class for all domain errors.""" + pass # --- Generic errors --- class EmptyFieldError(DomainError): """Raised when a required field is empty.""" + pass class InvalidLengthError(DomainError): """Raised when a field exceeds allowed length.""" - def __init__(self, field: str, min_length: int = None, max_length: int = None): - if min_length is not None: - super().__init__(f"{field} must be at least {min_length} characters long") - elif max_length is not None: - super().__init__(f"{field} exceeds maximum length of {max_length}") - else: + + def __init__( + self, + field: str, + min_length: int | None = None, + max_length: int | None = None, + ): + if min_length is not None: + super().__init__(f"{field} must be at least {min_length} characters long") + elif max_length is not None: + super().__init__(f"{field} exceeds maximum length of {max_length}") + else: super().__init__(f"Invalid length for {field}") class InvalidURLError(DomainError): """Raised when a URL is invalid.""" + pass class InvalidDateRangeError(DomainError): """Raised when start_date > end_date.""" + pass class InvalidOrderIndexError(DomainError): """Raised when order_index is invalid.""" + pass # --- Name / Title / Description --- class InvalidNameError(DomainError): """Raised when a name is invalid.""" + pass class InvalidTitleError(DomainError): """Raised when a title is invalid.""" + pass class InvalidDescriptionError(DomainError): """Raised when a description is invalid.""" + pass # --- Category / Skill --- class InvalidCategoryError(DomainError): """Raised when a category is invalid.""" + pass class InvalidSkillLevelError(DomainError): """Raised when a skill level is invalid.""" + pass # --- WorkExperience-specific --- class InvalidRoleError(DomainError): """Raised when a role is invalid.""" + pass class InvalidCompanyError(DomainError): """Raised when a company name is invalid.""" + pass # --- Certification-specific --- class InvalidIssuerError(DomainError): """Raised when the issuer is invalid.""" + pass # --- Education-specific --- class InvalidInstitutionError(DomainError): """Raised when the institution name is invalid.""" + pass # --- Contact / Social --- class InvalidEmailError(DomainError): """Raised when an email is invalid.""" + pass class InvalidPhoneError(DomainError): """Raised when a phone number is invalid.""" + pass # --- Project / Platform --- class InvalidPlatformError(DomainError): """Raised when a platform name is invalid.""" + pass # --- AdditionalTraining-specific --- class InvalidProviderError(DomainError): """Raised when the provider is invalid.""" + pass + class DuplicateValueError(DomainError): """Raised when a unique constraint is violated.""" - pass + pass diff --git a/app/domain/value_objects/contact_info.py b/app/domain/value_objects/contact_info.py index c508f10..489e2ab 100644 --- a/app/domain/value_objects/contact_info.py +++ b/app/domain/value_objects/contact_info.py @@ -11,7 +11,6 @@ """ from dataclasses import dataclass -from typing import Optional from .email import Email from .phone import Phone @@ -21,17 +20,17 @@ class ContactInfo: """ ContactInfo Value Object representing contact details. - + Attributes: email: Email address (required) phone: Phone number (optional) - + Business Rules: - Email is required - Phone is optional - Both must be valid if provided - Immutable after creation - + Examples: >>> info = ContactInfo.create( ... email="john@example.com", @@ -42,44 +41,40 @@ class ContactInfo: """ email: Email - phone: Optional[Phone] = None + phone: Phone | None = None @staticmethod - def create( - email: str, - phone: Optional[str] = None - ) -> "ContactInfo": + def create(email: str, phone: str | None = None) -> "ContactInfo": """ Factory method to create ContactInfo. - + Args: email: Email address string phone: Phone number string (optional) - + Returns: A new ContactInfo instance - + Raises: InvalidEmailError: If email format is invalid InvalidPhoneError: If phone format is invalid """ email_vo = Email.create(email) phone_vo = Phone.create(phone) if phone else None - + return ContactInfo(email=email_vo, phone=phone_vo) @staticmethod def from_value_objects( - email: Email, - phone: Optional[Phone] = None + email: Email, phone: Phone | None = None ) -> "ContactInfo": """ Create ContactInfo from existing Email and Phone VOs. - + Args: email: Email value object phone: Phone value object (optional) - + Returns: A new ContactInfo instance """ @@ -89,10 +84,10 @@ def from_value_objects( def email_only(email: str) -> "ContactInfo": """ Create ContactInfo with only email. - + Args: email: Email address string - + Returns: A new ContactInfo instance with no phone """ @@ -106,17 +101,17 @@ def get_email_value(self) -> str: """Get the email address as string.""" return self.email.value - def get_phone_value(self) -> Optional[str]: + def get_phone_value(self) -> str | None: """Get the phone number as string (None if not provided).""" return self.phone.value if self.phone else None def with_phone(self, phone: str) -> "ContactInfo": """ Create a new ContactInfo with a different phone. - + Args: phone: New phone number string - + Returns: A new ContactInfo instance """ @@ -126,7 +121,7 @@ def with_phone(self, phone: str) -> "ContactInfo": def without_phone(self) -> "ContactInfo": """ Create a new ContactInfo without phone. - + Returns: A new ContactInfo instance with no phone """ @@ -135,10 +130,10 @@ def without_phone(self) -> "ContactInfo": def with_email(self, email: str) -> "ContactInfo": """ Create a new ContactInfo with a different email. - + Args: email: New email address string - + Returns: A new ContactInfo instance """ @@ -164,4 +159,4 @@ def __eq__(self, other) -> bool: def __hash__(self) -> int: """Hash for use in sets and dicts.""" - return hash((self.email, self.phone)) \ No newline at end of file + return hash((self.email, self.phone)) diff --git a/app/domain/value_objects/date_range.py b/app/domain/value_objects/date_range.py index d3bdaaa..30af4ca 100644 --- a/app/domain/value_objects/date_range.py +++ b/app/domain/value_objects/date_range.py @@ -12,20 +12,19 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional -from ..exceptions import InvalidDateRangeError, EmptyFieldError +from ..exceptions import EmptyFieldError, InvalidDateRangeError @dataclass(frozen=True) class DateRange: """ DateRange Value Object representing a time period. - + Attributes: start_date: When the period starts (required) end_date: When the period ends (None if ongoing) - + Business Rules: - start_date is required - end_date must be after start_date if provided @@ -33,7 +32,7 @@ class DateRange: """ start_date: datetime - end_date: Optional[datetime] = None + end_date: datetime | None = None def __post_init__(self): """Validate invariants after initialization.""" @@ -41,19 +40,18 @@ def __post_init__(self): @staticmethod def create( - start_date: datetime, - end_date: Optional[datetime] = None + start_date: datetime, end_date: datetime | None = None ) -> "DateRange": """ Factory method to create a DateRange. - + Args: start_date: Start of the period end_date: End of the period (None if ongoing) - + Returns: A new DateRange instance - + Raises: EmptyFieldError: If start_date is None InvalidDateRangeError: If end_date is before start_date @@ -64,10 +62,10 @@ def create( def ongoing(start_date: datetime) -> "DateRange": """ Create an ongoing DateRange (no end date). - + Args: start_date: Start of the period - + Returns: A DateRange with no end date """ @@ -77,11 +75,11 @@ def ongoing(start_date: datetime) -> "DateRange": def completed(start_date: datetime, end_date: datetime) -> "DateRange": """ Create a completed DateRange (with end date). - + Args: start_date: Start of the period end_date: End of the period - + Returns: A DateRange with both start and end """ @@ -95,58 +93,61 @@ def is_completed(self) -> bool: """Check if this period has ended.""" return self.end_date is not None - def duration_days(self) -> Optional[int]: + def duration_days(self) -> int | None: """ Calculate duration in days. - + Returns: Number of days if completed, None if ongoing """ if self.is_ongoing(): return None + # Type assertion: end_date is not None after is_ongoing() check + assert self.end_date is not None return (self.end_date - self.start_date).days def contains_date(self, date: datetime) -> bool: """ Check if a given date falls within this range. - + Args: date: Date to check - + Returns: True if date is within range """ - if date < self.start_date: - return False - if self.end_date is not None and date > self.end_date: - return False - return True + return date >= self.start_date and ( + self.end_date is None or date <= self.end_date + ) def overlaps_with(self, other: "DateRange") -> bool: """ Check if this DateRange overlaps with another. - + Args: other: Another DateRange - + Returns: True if ranges overlap """ # If either range is ongoing, check if start dates overlap if self.is_ongoing() or other.is_ongoing(): - return self.start_date <= (other.end_date or datetime.max) and \ - other.start_date <= (self.end_date or datetime.max) - - # Both ranges have end dates + return self.start_date <= ( + other.end_date or datetime.max + ) and other.start_date <= (self.end_date or datetime.max) + + # Both ranges have end dates - add type assertions for MyPy + assert self.end_date is not None + assert other.end_date is not None return self.start_date <= other.end_date and other.start_date <= self.end_date def with_end_date(self, end_date: datetime) -> "DateRange": """ Create a new DateRange with a different end date. - + Args: end_date: New end date - + Returns: A new DateRange instance """ @@ -156,19 +157,18 @@ def _validate(self) -> None: """Validate the date range invariants.""" if self.start_date is None: raise EmptyFieldError("start_date") - + if self.end_date is not None and self.end_date <= self.start_date: - raise InvalidDateRangeError( - str(self.start_date), - str(self.end_date) - ) + raise InvalidDateRangeError(str(self.start_date), str(self.end_date)) def __str__(self) -> str: """String representation for display.""" if self.is_ongoing(): return f"{self.start_date.strftime('%Y-%m-%d')} - Present" + # Type assertion: end_date is not None after is_ongoing() check + assert self.end_date is not None return f"{self.start_date.strftime('%Y-%m-%d')} - {self.end_date.strftime('%Y-%m-%d')}" def __repr__(self) -> str: """String representation for debugging.""" - return f"DateRange(start_date={self.start_date}, end_date={self.end_date})" \ No newline at end of file + return f"DateRange(start_date={self.start_date}, end_date={self.end_date})" diff --git a/app/domain/value_objects/email.py b/app/domain/value_objects/email.py index 837d2ac..690ae65 100644 --- a/app/domain/value_objects/email.py +++ b/app/domain/value_objects/email.py @@ -9,20 +9,20 @@ - Equality by value: Two Emails are equal if addresses match """ -from dataclasses import dataclass import re +from dataclasses import dataclass -from ..exceptions import InvalidEmailError, EmptyFieldError +from ..exceptions import EmptyFieldError, InvalidEmailError @dataclass(frozen=True) class Email: """ Email Value Object representing a validated email address. - + Attributes: value: The email address string - + Business Rules: - Must be non-empty - Must match valid email pattern @@ -33,27 +33,25 @@ class Email: value: str # Email validation pattern (simplified RFC 5322) - EMAIL_PATTERN = re.compile( - r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - ) + EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") def __post_init__(self): """Validate and normalize email after initialization.""" # Use object.__setattr__ because dataclass is frozen - object.__setattr__(self, 'value', self.value.strip().lower()) + object.__setattr__(self, "value", self.value.strip().lower()) self._validate() @staticmethod def create(value: str) -> "Email": """ Factory method to create an Email. - + Args: value: Email address string - + Returns: A new Email instance - + Raises: EmptyFieldError: If value is empty InvalidEmailError: If format is invalid @@ -64,10 +62,10 @@ def create(value: str) -> "Email": def try_create(value: str) -> "Email | None": """ Try to create an Email, returning None if invalid. - + Args: value: Email address string - + Returns: Email instance or None if invalid """ @@ -79,28 +77,28 @@ def try_create(value: str) -> "Email | None": def get_local_part(self) -> str: """ Get the local part (before @) of the email. - + Returns: Local part of email (e.g., 'user' from 'user@example.com') """ - return self.value.split('@')[0] + return self.value.split("@")[0] def get_domain(self) -> str: """ Get the domain part (after @) of the email. - + Returns: Domain part of email (e.g., 'example.com' from 'user@example.com') """ - return self.value.split('@')[1] + return self.value.split("@")[1] def is_from_domain(self, domain: str) -> bool: """ Check if email is from a specific domain. - + Args: domain: Domain to check (e.g., 'gmail.com') - + Returns: True if email is from the specified domain """ @@ -110,7 +108,7 @@ def _validate(self) -> None: """Validate the email format.""" if not self.value: raise EmptyFieldError("email") - + if not self.EMAIL_PATTERN.match(self.value): raise InvalidEmailError(self.value) @@ -130,4 +128,4 @@ def __eq__(self, other) -> bool: def __hash__(self) -> int: """Hash for use in sets and dicts.""" - return hash(self.value) \ No newline at end of file + return hash(self.value) diff --git a/app/domain/value_objects/phone.py b/app/domain/value_objects/phone.py index c088031..10aea21 100644 --- a/app/domain/value_objects/phone.py +++ b/app/domain/value_objects/phone.py @@ -42,15 +42,13 @@ class Phone: # Pattern to extract digits from formatted numbers DIGIT_PATTERN = re.compile(r"[\d+]+") - - + def __post_init__(self): original = self.value normalized = self._normalize(original) object.__setattr__(self, "value", normalized) self._validate(original) - @staticmethod def create(value: str) -> "Phone": """ @@ -178,7 +176,7 @@ def _normalize(self, value: str) -> str: return normalized return normalized - + def _validate(self, original: str) -> None: # Caso 1: el usuario realmente envió vacío if original is None or original.strip() == "": @@ -196,7 +194,6 @@ def _validate(self, original: str) -> None: if not self.E164_PATTERN.match(self.value): raise InvalidPhoneError(f"Invalid phone number: {original}") - def __str__(self) -> str: """String representation for display (formatted).""" return self.format_international() diff --git a/app/domain/value_objects/skill_level.py b/app/domain/value_objects/skill_level.py index 68eea07..6d6bae0 100644 --- a/app/domain/value_objects/skill_level.py +++ b/app/domain/value_objects/skill_level.py @@ -10,16 +10,17 @@ - Ordered: Levels can be compared """ +from __future__ import annotations + from dataclasses import dataclass from enum import Enum -from typing import Optional from ..exceptions import InvalidSkillLevelError class SkillLevelEnum(Enum): """Enumeration of valid skill proficiency levels.""" - + BASIC = "basic" INTERMEDIATE = "intermediate" ADVANCED = "advanced" @@ -32,7 +33,7 @@ def __lt__(self, other) -> bool: """Allow ordering of skill levels.""" if not isinstance(other, SkillLevelEnum): return NotImplemented - + order = { SkillLevelEnum.BASIC: 1, SkillLevelEnum.INTERMEDIATE: 2, @@ -50,15 +51,15 @@ def display_name(self) -> str: class SkillLevel: """ SkillLevel Value Object representing skill proficiency. - + Attributes: level: The skill level enumeration - + Business Rules: - Must be one of: basic, intermediate, advanced, expert - Immutable after creation - Can be compared/ordered - + Examples: >>> basic = SkillLevel.basic() >>> advanced = SkillLevel.advanced() @@ -69,35 +70,35 @@ class SkillLevel: level: SkillLevelEnum @staticmethod - def create(value: str) -> "SkillLevel": + def create(value: str) -> SkillLevel: """ Factory method to create a SkillLevel from string. - + Args: value: Skill level string (case-insensitive) - + Returns: A new SkillLevel instance - + Raises: InvalidSkillLevelError: If value is not a valid level """ normalized = value.lower().strip() - + try: enum_level = SkillLevelEnum(normalized) return SkillLevel(level=enum_level) except ValueError: - raise InvalidSkillLevelError(value) + raise InvalidSkillLevelError(value) from None @staticmethod - def try_create(value: str) -> Optional["SkillLevel"]: + def try_create(value: str) -> SkillLevel | None: """ Try to create a SkillLevel, returning None if invalid. - + Args: value: Skill level string - + Returns: SkillLevel instance or None if invalid """ @@ -107,30 +108,30 @@ def try_create(value: str) -> Optional["SkillLevel"]: return None @staticmethod - def basic() -> "SkillLevel": + def basic() -> SkillLevel: """Create a BASIC skill level.""" return SkillLevel(level=SkillLevelEnum.BASIC) @staticmethod - def intermediate() -> "SkillLevel": + def intermediate() -> SkillLevel: """Create an INTERMEDIATE skill level.""" return SkillLevel(level=SkillLevelEnum.INTERMEDIATE) @staticmethod - def advanced() -> "SkillLevel": + def advanced() -> SkillLevel: """Create an ADVANCED skill level.""" return SkillLevel(level=SkillLevelEnum.ADVANCED) @staticmethod - def expert() -> "SkillLevel": + def expert() -> SkillLevel: """Create an EXPERT skill level.""" return SkillLevel(level=SkillLevelEnum.EXPERT) @staticmethod - def all_levels() -> list["SkillLevel"]: + def all_levels() -> list[SkillLevel]: """ Get all valid skill levels in order. - + Returns: List of all SkillLevel instances """ @@ -157,13 +158,13 @@ def is_expert(self) -> bool: """Check if this is expert level.""" return self.level == SkillLevelEnum.EXPERT - def is_at_least(self, other: "SkillLevel") -> bool: + def is_at_least(self, other: SkillLevel) -> bool: """ Check if this level is at least as high as another. - + Args: other: Another SkillLevel to compare - + Returns: True if this level >= other level """ @@ -199,6 +200,8 @@ def __lt__(self, other) -> bool: def __le__(self, other) -> bool: """Less than or equal comparison.""" + if not isinstance(other, SkillLevel): + return NotImplemented return self == other or self < other def __gt__(self, other) -> bool: @@ -209,8 +212,10 @@ def __gt__(self, other) -> bool: def __ge__(self, other) -> bool: """Greater than or equal comparison.""" + if not isinstance(other, SkillLevel): + return NotImplemented return self == other or self > other def __hash__(self) -> int: """Hash for use in sets and dicts.""" - return hash(self.level) \ No newline at end of file + return hash(self.level) diff --git a/app/infrastructure/database/database.py b/app/infrastructure/database/database.py index ce8e0fa..489759e 100644 --- a/app/infrastructure/database/database.py +++ b/app/infrastructure/database/database.py @@ -1,21 +1,30 @@ from motor.motor_asyncio import AsyncIOMotorClient + from app.config.settings import settings + class MongoDB: - client: AsyncIOMotorClient = None - + client: AsyncIOMotorClient | None = None + + db = MongoDB() + async def connect_to_mongo(): """Conectar a MongoDB""" - db.client = AsyncIOMotorClient(settings.mongodb_url) - print(f"✅ Conectado a MongoDB: {settings.database_name}") + db.client = AsyncIOMotorClient(settings.MONGODB_URL) + print(f"✅ Conectado a MongoDB: {settings.MONGODB_DB_NAME}") + async def close_mongo_connection(): """Cerrar conexión a MongoDB""" - db.client.close() - print("❌ Conexión a MongoDB cerrada") + if db.client: + db.client.close() + print("❌ Conexión a MongoDB cerrada") + def get_database(): """Obtener instancia de la base de datos""" - return db.client[settings.database_name] \ No newline at end of file + if db.client is None: + raise RuntimeError("MongoDB client not initialized. Call connect_to_mongo() first.") + return db.client[settings.MONGODB_DB_NAME] diff --git a/app/infrastructure/database/mongo_client.py b/app/infrastructure/database/mongo_client.py index 58b56da..b19b84f 100644 --- a/app/infrastructure/database/mongo_client.py +++ b/app/infrastructure/database/mongo_client.py @@ -1,16 +1,18 @@ +import logging + from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase + from app.config.settings import settings -import logging logger = logging.getLogger(__name__) class MongoDBClient: """Cliente MongoDB usando Motor (async)""" - - client: AsyncIOMotorClient = None - db: AsyncIOMotorDatabase = None - + + client: AsyncIOMotorClient | None = None + db: AsyncIOMotorDatabase | None = None + @classmethod async def connect(cls): """Conectar a MongoDB""" @@ -18,15 +20,15 @@ async def connect(cls): logger.info(f"Conectando a MongoDB: {settings.MONGODB_URL}") cls.client = AsyncIOMotorClient(settings.MONGODB_URL) cls.db = cls.client[settings.MONGODB_DB_NAME] - + # Test de conexión - await cls.client.admin.command('ping') + await cls.client.admin.command("ping") logger.info(f"✓ Conectado a MongoDB: {settings.MONGODB_DB_NAME}") - + except Exception as e: logger.error(f"✗ Error conectando a MongoDB: {e}") raise - + @classmethod async def disconnect(cls): """Desconectar de MongoDB""" @@ -34,16 +36,18 @@ async def disconnect(cls): logger.info("Desconectando de MongoDB") cls.client.close() logger.info("✓ Desconectado de MongoDB") - + @classmethod def get_db(cls) -> AsyncIOMotorDatabase: """Obtener instancia de la base de datos""" if cls.db is None: - raise RuntimeError("Base de datos no inicializada. Llama a connect() primero.") + raise RuntimeError( + "Base de datos no inicializada. Llama a connect() primero." + ) return cls.db # Función helper para dependency injection en FastAPI async def get_database() -> AsyncIOMotorDatabase: """Dependency para obtener la BD en los routers""" - return MongoDBClient.get_db() \ No newline at end of file + return MongoDBClient.get_db() diff --git a/app/main.py b/app/main.py index c1180ff..a45c21f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,22 +1,22 @@ -from fastapi import FastAPI -from contextlib import asynccontextmanager import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI -from app.config.settings import settings -from app.infrastructure.database.mongo_client import MongoDBClient from app.api.middleware import setup_middleware from app.api.v1.router import api_v1_router +from app.config.settings import settings +from app.infrastructure.database.mongo_client import MongoDBClient # Configurar logging logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(_app: FastAPI): """ Gestión del ciclo de vida de la aplicación. Se ejecuta al inicio y al cierre del servidor. @@ -24,12 +24,12 @@ async def lifespan(app: FastAPI): # Startup logger.info(f"🚀 Iniciando {settings.PROJECT_NAME} v{settings.VERSION}") logger.info(f"📍 Entorno: {settings.ENVIRONMENT}") - + # Conectar a MongoDB await MongoDBClient.connect() - + yield # Aquí la aplicación está corriendo - + # Shutdown logger.info("🛑 Deteniendo aplicación...") await MongoDBClient.disconnect() @@ -44,17 +44,14 @@ async def lifespan(app: FastAPI): lifespan=lifespan, docs_url="/docs", redoc_url="/redoc", - openapi_url="/openapi.json" + openapi_url="/openapi.json", ) # Configurar middlewares setup_middleware(app) # Incluir routers -app.include_router( - api_v1_router, - prefix=settings.API_V1_PREFIX -) +app.include_router(api_v1_router, prefix=settings.API_V1_PREFIX) @app.get("/") @@ -64,17 +61,17 @@ async def root(): "message": f"¡Bienvenido a {settings.PROJECT_NAME}!", "version": settings.VERSION, "docs": "/docs", - "health": f"{settings.API_V1_PREFIX}/health" + "health": f"{settings.API_V1_PREFIX}/health", } if __name__ == "__main__": import uvicorn - + uvicorn.run( "app.main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG, - log_level="info" - ) \ No newline at end of file + log_level="info", + ) diff --git a/app/shared/__init__.py b/app/shared/__init__.py index 181f39b..0fa8fe1 100644 --- a/app/shared/__init__.py +++ b/app/shared/__init__.py @@ -22,22 +22,19 @@ """ # Import interfaces for easy access -from .interfaces import ( - # Repository interfaces - IRepository, - IProfileRepository, - IOrderedRepository, +from .interfaces import ( # Repository interfaces; Use case interfaces; Mapper interfaces + ICommandUseCase, IContactMessageRepository, - IUniqueNameRepository, + IDTOMapper, + IMapper, + IOrderedRepository, + IProfileRepository, + IQueryUseCase, + IRepository, ISocialNetworkRepository, - # Use case interfaces + IUniqueNameRepository, IUseCase, - IQueryUseCase, - ICommandUseCase, IValidator, - # Mapper interfaces - IMapper, - IDTOMapper, IValueObjectMapper, ) @@ -60,4 +57,4 @@ "IValueObjectMapper", ] -__version__ = "0.1.0" \ No newline at end of file +__version__ = "0.1.0" diff --git a/app/shared/interfaces/__init__.py b/app/shared/interfaces/__init__.py index dbed5ef..0904524 100644 --- a/app/shared/interfaces/__init__.py +++ b/app/shared/interfaces/__init__.py @@ -19,42 +19,32 @@ while the infrastructure layer provides concrete implementations. """ +# Mapper interfaces +from .mapper import IDTOMapper, IMapper, IValueObjectMapper + # Repository interfaces -from .repository import ( - IRepository, - IProfileRepository, - IOrderedRepository, +from .repository import ( # Type aliases + AdditionalTrainingRepository, + CertificationRepository, + ContactInformationRepository, + ContactMessageRepository, + EducationRepository, IContactMessageRepository, - IUniqueNameRepository, + IOrderedRepository, + IProfileRepository, + IRepository, ISocialNetworkRepository, - # Type aliases + IUniqueNameRepository, ProfileRepository, - WorkExperienceRepository, - SkillRepository, - EducationRepository, ProjectRepository, - CertificationRepository, - AdditionalTrainingRepository, - ContactInformationRepository, - ContactMessageRepository, + SkillRepository, SocialNetworkRepository, ToolRepository, + WorkExperienceRepository, ) # Use case interfaces -from .use_case import ( - IUseCase, - IQueryUseCase, - ICommandUseCase, - IValidator, -) - -# Mapper interfaces -from .mapper import ( - IMapper, - IDTOMapper, - IValueObjectMapper, -) +from .use_case import ICommandUseCase, IQueryUseCase, IUseCase, IValidator __all__ = [ # Repository interfaces @@ -85,4 +75,4 @@ "IMapper", "IDTOMapper", "IValueObjectMapper", -] \ No newline at end of file +] diff --git a/app/shared/interfaces/mapper.py b/app/shared/interfaces/mapper.py index 037e059..dd85be5 100644 --- a/app/shared/interfaces/mapper.py +++ b/app/shared/interfaces/mapper.py @@ -17,28 +17,28 @@ """ from abc import ABC, abstractmethod -from typing import Generic, TypeVar, List +from typing import Generic, TypeVar # Generic types for domain and persistence models -TDomain = TypeVar('TDomain') -TPersistence = TypeVar('TPersistence') +TDomain = TypeVar("TDomain") +TPersistence = TypeVar("TPersistence") class IMapper(ABC, Generic[TDomain, TPersistence]): """ Generic Mapper Interface. - + Defines the contract for converting between domain entities and persistence models (or other representations). - + Type Parameters: TDomain: The domain entity type TPersistence: The persistence model type (e.g., MongoDB document dict) - + Design Patterns: - Data Mapper Pattern: Separates domain logic from persistence - Adapter Pattern: Adapts between different representations - + Usage: class ProfileMapper(IMapper[Profile, Dict[str, Any]]): def to_domain(self, persistence_model: Dict[str, Any]) -> Profile: @@ -47,14 +47,14 @@ def to_domain(self, persistence_model: Dict[str, Any]) -> Profile: name=persistence_model['name'], # ... more fields ) - + def to_persistence(self, domain_entity: Profile) -> Dict[str, Any]: return { '_id': ObjectId(domain_entity.id), 'name': domain_entity.name, # ... more fields } - + Notes: - Mappers belong in the infrastructure layer - Mappers know about both domain and persistence representations @@ -66,19 +66,19 @@ def to_persistence(self, domain_entity: Profile) -> Dict[str, Any]: def to_domain(self, persistence_model: TPersistence) -> TDomain: """ Convert from persistence model to domain entity. - + Args: persistence_model: The persistence representation - + Returns: The domain entity - + Notes: - Should reconstruct full domain entity - Should handle type conversions - Should validate data integrity - May raise exceptions for invalid data - + Examples: profile = mapper.to_domain(mongo_document) """ @@ -88,50 +88,50 @@ def to_domain(self, persistence_model: TPersistence) -> TDomain: def to_persistence(self, domain_entity: TDomain) -> TPersistence: """ Convert from domain entity to persistence model. - + Args: domain_entity: The domain entity - + Returns: The persistence representation - + Notes: - Should extract all necessary fields - Should handle type conversions - Should prepare data for storage - Should not include computed fields - + Examples: mongo_document = mapper.to_persistence(profile) """ pass - def to_domain_list(self, persistence_models: List[TPersistence]) -> List[TDomain]: + def to_domain_list(self, persistence_models: list[TPersistence]) -> list[TDomain]: """ Convert a list of persistence models to domain entities. - + Args: persistence_models: List of persistence representations - + Returns: List of domain entities - + Notes: - Default implementation maps each item - Can be overridden for optimization """ return [self.to_domain(model) for model in persistence_models] - def to_persistence_list(self, domain_entities: List[TDomain]) -> List[TPersistence]: + def to_persistence_list(self, domain_entities: list[TDomain]) -> list[TPersistence]: """ Convert a list of domain entities to persistence models. - + Args: domain_entities: List of domain entities - + Returns: List of persistence representations - + Notes: - Default implementation maps each item - Can be overridden for optimization @@ -142,14 +142,14 @@ def to_persistence_list(self, domain_entities: List[TDomain]) -> List[TPersisten class IDTOMapper(ABC, Generic[TDomain, TPersistence]): """ DTO Mapper Interface. - + Specialized mapper for converting between domain entities and DTOs (Data Transfer Objects) used in the API layer. - + Type Parameters: TDomain: The domain entity type TPersistence: The DTO type (usually Pydantic model) - + Usage: class ProfileDTOMapper(IDTOMapper[Profile, ProfileDTO]): def to_dto(self, domain_entity: Profile) -> ProfileDTO: @@ -158,14 +158,14 @@ def to_dto(self, domain_entity: Profile) -> ProfileDTO: name=domain_entity.name, # ... more fields ) - + def to_domain(self, dto: ProfileDTO) -> Profile: return Profile( id=dto.id, name=dto.name, # ... more fields ) - + Notes: - DTOs are typically Pydantic models in FastAPI - DTOs may have different structure than domain entities @@ -177,13 +177,13 @@ def to_domain(self, dto: ProfileDTO) -> Profile: def to_dto(self, domain_entity: TDomain) -> TPersistence: """ Convert from domain entity to DTO. - + Args: domain_entity: The domain entity - + Returns: The DTO representation - + Notes: - Should include all fields needed by API consumers - May include computed fields @@ -196,13 +196,13 @@ def to_dto(self, domain_entity: TDomain) -> TPersistence: def from_dto(self, dto: TPersistence) -> TDomain: """ Convert from DTO to domain entity. - + Args: dto: The DTO - + Returns: The domain entity - + Notes: - Should validate business rules - Should reconstruct nested objects @@ -211,11 +211,11 @@ def from_dto(self, dto: TPersistence) -> TDomain: """ pass - def to_dto_list(self, domain_entities: List[TDomain]) -> List[TPersistence]: + def to_dto_list(self, domain_entities: list[TDomain]) -> list[TPersistence]: """Convert a list of domain entities to DTOs.""" return [self.to_dto(entity) for entity in domain_entities] - def from_dto_list(self, dtos: List[TPersistence]) -> List[TDomain]: + def from_dto_list(self, dtos: list[TPersistence]) -> list[TDomain]: """Convert a list of DTOs to domain entities.""" return [self.from_dto(dto) for dto in dtos] @@ -223,21 +223,21 @@ def from_dto_list(self, dtos: List[TPersistence]) -> List[TDomain]: class IValueObjectMapper(ABC, Generic[TDomain, TPersistence]): """ Value Object Mapper Interface. - + Specialized mapper for converting value objects between representations. - + Type Parameters: TDomain: The value object type TPersistence: The primitive/dict representation - + Usage: class EmailMapper(IValueObjectMapper[Email, str]): def to_primitive(self, value_object: Email) -> str: return value_object.value - + def from_primitive(self, primitive: str) -> Email: return Email.create(primitive) - + Notes: - Value objects often map to primitives (str, int, dict) - Value objects validate on construction @@ -248,10 +248,10 @@ def from_primitive(self, primitive: str) -> Email: def to_primitive(self, value_object: TDomain) -> TPersistence: """ Convert value object to primitive representation. - + Args: value_object: The value object - + Returns: Primitive representation (str, int, dict, etc.) """ @@ -261,14 +261,14 @@ def to_primitive(self, value_object: TDomain) -> TPersistence: def from_primitive(self, primitive: TPersistence) -> TDomain: """ Convert primitive to value object. - + Args: primitive: Primitive representation - + Returns: The value object - + Raises: ValidationError: If primitive is invalid """ - pass \ No newline at end of file + pass diff --git a/app/shared/interfaces/repository.py b/app/shared/interfaces/repository.py index 062adbc..bde30ee 100644 --- a/app/shared/interfaces/repository.py +++ b/app/shared/interfaces/repository.py @@ -15,49 +15,41 @@ """ from abc import ABC, abstractmethod -from typing import Generic, TypeVar, Optional, List, Dict, Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Generic, TypeVar # Import entities only for type checking to avoid circular imports if TYPE_CHECKING: from app.domain.entities import ( - Profile, - WorkExperience, - Skill, - Education, - Project, - Certification, - AdditionalTraining, - ContactInformation, ContactMessage, + Profile, SocialNetwork, - Tool, ) # Generic type representing any domain entity -T = TypeVar('T') +T = TypeVar("T") class IRepository(ABC, Generic[T]): """ Generic Repository Interface defining the contract for data persistence. - + This interface abstracts away data storage details, allowing the domain and application layers to remain independent of infrastructure choices. - + Type Parameters: T: The domain entity type this repository manages - + Design Patterns: - Repository Pattern: Mediates between domain and data mapping layers - Dependency Inversion: High-level modules depend on abstractions - Interface Segregation: Minimal, focused contract - + Usage: class ProfileRepository(IRepository[Profile]): async def add(self, entity: Profile) -> Profile: # MongoDB implementation ... - + Notes: - All methods are async to support async database drivers (Motor for MongoDB) - Methods should raise domain exceptions on failure @@ -68,17 +60,17 @@ async def add(self, entity: Profile) -> Profile: async def add(self, entity: T) -> T: """ Add a new entity to the repository. - + Args: entity: The domain entity to persist - + Returns: The persisted entity (may include generated fields like timestamps) - + Raises: DuplicateValueError: If entity with same unique constraint already exists DomainError: For other business rule violations - + Notes: - Should validate entity before persisting - Should handle ID generation if needed @@ -91,17 +83,17 @@ async def add(self, entity: T) -> T: async def update(self, entity: T) -> T: """ Update an existing entity in the repository. - + Args: entity: The domain entity with updated values - + Returns: The updated entity - + Raises: NotFoundException: If entity with given ID doesn't exist DomainError: For business rule violations - + Notes: - Should validate entity before persisting - Should update updated_at timestamp @@ -114,13 +106,13 @@ async def update(self, entity: T) -> T: async def delete(self, entity_id: str) -> bool: """ Delete an entity from the repository. - + Args: entity_id: The unique identifier of the entity to delete - + Returns: True if entity was deleted, False if not found - + Notes: - Should be idempotent (deleting non-existent entity returns False) - Should handle cascade deletes if needed @@ -129,16 +121,16 @@ async def delete(self, entity_id: str) -> bool: pass @abstractmethod - async def get_by_id(self, entity_id: str) -> Optional[T]: + async def get_by_id(self, entity_id: str) -> T | None: """ Retrieve an entity by its unique identifier. - + Args: entity_id: The unique identifier of the entity - + Returns: The entity if found, None otherwise - + Notes: - Should return None instead of raising exception if not found - Should reconstruct full domain entity from persistence model @@ -150,21 +142,21 @@ async def list_all( self, skip: int = 0, limit: int = 100, - sort_by: Optional[str] = None, - ascending: bool = True - ) -> List[T]: + sort_by: str | None = None, + ascending: bool = True, + ) -> list[T]: """ List all entities with optional pagination and sorting. - + Args: skip: Number of entities to skip (for pagination) limit: Maximum number of entities to return sort_by: Field name to sort by (optional) ascending: Sort direction (True for ascending, False for descending) - + Returns: List of entities (may be empty) - + Notes: - Should support pagination for large datasets - Should support sorting by any entity field @@ -174,16 +166,16 @@ async def list_all( pass @abstractmethod - async def count(self, filters: Optional[Dict[str, Any]] = None) -> int: + async def count(self, filters: dict[str, Any] | None = None) -> int: """ Count entities matching optional filters. - + Args: filters: Optional dictionary of field:value filters - + Returns: Number of entities matching filters - + Notes: - Should support common filter operations - Should return 0 if no entities match @@ -195,13 +187,13 @@ async def count(self, filters: Optional[Dict[str, Any]] = None) -> int: async def exists(self, entity_id: str) -> bool: """ Check if an entity exists by its unique identifier. - + Args: entity_id: The unique identifier of the entity - + Returns: True if entity exists, False otherwise - + Notes: - Should be more efficient than get_by_id when only existence matters - Useful for validation logic @@ -209,23 +201,23 @@ async def exists(self, entity_id: str) -> bool: pass @abstractmethod - async def find_by(self, **filters) -> List[T]: + async def find_by(self, **filters) -> list[T]: """ Find entities matching specified filters. - + Args: **filters: Arbitrary keyword arguments representing field:value filters - + Returns: List of entities matching all filters (may be empty) - + Examples: # Find by single field profiles = await repo.find_by(name="John Doe") - + # Find by multiple fields skills = await repo.find_by(category="Programming", level="expert") - + Notes: - Should support equality filters on any entity field - Should return empty list if no entities match @@ -234,22 +226,22 @@ async def find_by(self, **filters) -> List[T]: pass -class IProfileRepository(IRepository['Profile']): +class IProfileRepository(IRepository["Profile"]): """ Profile-specific repository interface. - + Extends the generic repository with profile-specific queries. Only ONE profile should exist in the system. """ @abstractmethod - async def get_profile(self) -> Optional['Profile']: + async def get_profile(self) -> "Profile" | None: """ Get the single profile (only one should exist). - + Returns: The profile if it exists, None otherwise - + Business Rule: Only one profile can exist in the system """ @@ -259,10 +251,10 @@ async def get_profile(self) -> Optional['Profile']: async def profile_exists(self) -> bool: """ Check if a profile already exists. - + Returns: True if profile exists, False otherwise - + Business Rule: Used to enforce single profile constraint """ @@ -272,37 +264,35 @@ async def profile_exists(self) -> bool: class IOrderedRepository(IRepository[T], Generic[T]): """ Repository interface for entities with ordering (orderIndex). - + Adds methods to manage entity ordering. """ @abstractmethod - async def get_by_order_index(self, profile_id: str, order_index: int) -> Optional[T]: + async def get_by_order_index( + self, profile_id: str, order_index: int + ) -> T | None: """ Get entity by its order index within a profile. - + Args: profile_id: The profile ID order_index: The order index - + Returns: The entity if found, None otherwise """ pass @abstractmethod - async def get_all_ordered( - self, - profile_id: str, - ascending: bool = True - ) -> List[T]: + async def get_all_ordered(self, profile_id: str, ascending: bool = True) -> list[T]: """ Get all entities for a profile, sorted by orderIndex. - + Args: profile_id: The profile ID ascending: Sort direction - + Returns: List of entities sorted by orderIndex """ @@ -310,19 +300,16 @@ async def get_all_ordered( @abstractmethod async def reorder( - self, - profile_id: str, - entity_id: str, - new_order_index: int + self, profile_id: str, entity_id: str, new_order_index: int ) -> None: """ Reorder an entity and adjust other entities' orderIndex accordingly. - + Args: profile_id: The profile ID entity_id: The entity to reorder new_order_index: The new order index - + Notes: - Should adjust other entities to maintain unique orderIndex - Should handle gaps in ordering @@ -330,31 +317,31 @@ async def reorder( pass -class IContactMessageRepository(IRepository['ContactMessage']): +class IContactMessageRepository(IRepository["ContactMessage"]): """ ContactMessage-specific repository interface. - + Extends the generic repository with message-specific queries. """ @abstractmethod - async def get_pending_messages(self) -> List['ContactMessage']: + async def get_pending_messages(self) -> list["ContactMessage"]: """ Get all pending contact messages. - + Returns: List of messages with status='pending' """ pass @abstractmethod - async def get_messages_by_status(self, status: str) -> List['ContactMessage']: + async def get_messages_by_status(self, status: str) -> list["ContactMessage"]: """ Get messages filtered by status. - + Args: status: Message status (pending, read, replied) - + Returns: List of messages with specified status """ @@ -364,10 +351,10 @@ async def get_messages_by_status(self, status: str) -> List['ContactMessage']: async def mark_as_read(self, message_id: str) -> bool: """ Mark a message as read. - + Args: message_id: The message ID - + Returns: True if updated, False if not found """ @@ -377,10 +364,10 @@ async def mark_as_read(self, message_id: str) -> bool: async def mark_as_replied(self, message_id: str) -> bool: """ Mark a message as replied. - + Args: message_id: The message ID - + Returns: True if updated, False if not found """ @@ -390,7 +377,7 @@ async def mark_as_replied(self, message_id: str) -> bool: class IUniqueNameRepository(IRepository[T], Generic[T]): """ Repository interface for entities with unique names per profile. - + Used for Skill and Tool entities. """ @@ -398,35 +385,35 @@ class IUniqueNameRepository(IRepository[T], Generic[T]): async def exists_by_name(self, profile_id: str, name: str) -> bool: """ Check if an entity with the given name exists for the profile. - + Args: profile_id: The profile ID name: The name to check - + Returns: True if exists, False otherwise """ pass @abstractmethod - async def get_by_name(self, profile_id: str, name: str) -> Optional[T]: + async def get_by_name(self, profile_id: str, name: str) -> T | None: """ Get entity by name within a profile. - + Args: profile_id: The profile ID name: The name to search for - + Returns: The entity if found, None otherwise """ pass -class ISocialNetworkRepository(IRepository['SocialNetwork']): +class ISocialNetworkRepository(IRepository["SocialNetwork"]): """ SocialNetwork-specific repository interface. - + Extends the generic repository with social network-specific queries. """ @@ -434,28 +421,30 @@ class ISocialNetworkRepository(IRepository['SocialNetwork']): async def exists_by_platform(self, profile_id: str, platform: str) -> bool: """ Check if a social network with the given platform exists. - + Args: profile_id: The profile ID platform: The platform name (e.g., "LinkedIn", "GitHub") - + Returns: True if exists, False otherwise - + Business Rule: Only one social network per platform per profile """ pass @abstractmethod - async def get_by_platform(self, profile_id: str, platform: str) -> Optional['SocialNetwork']: + async def get_by_platform( + self, profile_id: str, platform: str + ) -> "SocialNetwork" | None: """ Get social network by platform within a profile. - + Args: profile_id: The profile ID platform: The platform name - + Returns: The social network if found, None otherwise """ @@ -465,13 +454,13 @@ async def get_by_platform(self, profile_id: str, platform: str) -> Optional['Soc # Type aliases for convenience # Using string literals to avoid circular imports at runtime ProfileRepository = IProfileRepository -WorkExperienceRepository = IOrderedRepository['WorkExperience'] -SkillRepository = IUniqueNameRepository['Skill'] -EducationRepository = IOrderedRepository['Education'] -ProjectRepository = IOrderedRepository['Project'] -CertificationRepository = IOrderedRepository['Certification'] -AdditionalTrainingRepository = IOrderedRepository['AdditionalTraining'] -ContactInformationRepository = IRepository['ContactInformation'] +WorkExperienceRepository = IOrderedRepository["WorkExperience"] +SkillRepository = IUniqueNameRepository["Skill"] +EducationRepository = IOrderedRepository["Education"] +ProjectRepository = IOrderedRepository["Project"] +CertificationRepository = IOrderedRepository["Certification"] +AdditionalTrainingRepository = IOrderedRepository["AdditionalTraining"] +ContactInformationRepository = IRepository["ContactInformation"] ContactMessageRepository = IContactMessageRepository SocialNetworkRepository = ISocialNetworkRepository -ToolRepository = IUniqueNameRepository['Tool'] \ No newline at end of file +ToolRepository = IUniqueNameRepository["Tool"] diff --git a/app/shared/interfaces/use_case.py b/app/shared/interfaces/use_case.py index ddbfe0c..1688f1c 100644 --- a/app/shared/interfaces/use_case.py +++ b/app/shared/interfaces/use_case.py @@ -18,35 +18,35 @@ from typing import Generic, TypeVar # Generic types for request and response -TRequest = TypeVar('TRequest') -TResponse = TypeVar('TResponse') +TRequest = TypeVar("TRequest") +TResponse = TypeVar("TResponse") class IUseCase(ABC, Generic[TRequest, TResponse]): """ Generic Use Case Interface. - + Defines the contract for all use cases in the application. Each use case encapsulates a single business operation. - + Type Parameters: TRequest: The input type (DTO, command, query) TResponse: The output type (DTO, result, data) - + Design Patterns: - Command Pattern: Encapsulates a request as an object - Single Responsibility: Each use case does one thing - Interface Segregation: Minimal contract - + Usage: class GetProfileUseCase(IUseCase[GetProfileRequest, GetProfileResponse]): def __init__(self, profile_repo: IProfileRepository): self.profile_repo = profile_repo - + async def execute(self, request: GetProfileRequest) -> GetProfileResponse: profile = await self.profile_repo.get_profile() return GetProfileResponse.from_entity(profile) - + Notes: - Use cases are async to support async repositories - Use cases should not contain UI logic or HTTP details @@ -58,17 +58,17 @@ async def execute(self, request: GetProfileRequest) -> GetProfileResponse: async def execute(self, request: TRequest) -> TResponse: """ Execute the use case with the given request. - + Args: request: The input data/command - + Returns: The result of the operation - + Raises: DomainError: For business rule violations ValidationError: For invalid input (if not caught at DTO level) - + Notes: - Should be idempotent when possible - Should handle errors gracefully @@ -81,20 +81,20 @@ async def execute(self, request: TRequest) -> TResponse: class IQueryUseCase(ABC, Generic[TRequest, TResponse]): """ Query Use Case Interface. - + Specialized interface for read-only operations (queries). Follows CQRS (Command Query Responsibility Segregation) pattern. - + Type Parameters: TRequest: The query parameters TResponse: The query result - + Usage: class GetAllSkillsQuery(IQueryUseCase[GetAllSkillsRequest, GetAllSkillsResponse]): async def execute(self, request: GetAllSkillsRequest) -> GetAllSkillsResponse: # Read-only operation ... - + Notes: - Queries should never modify state - Can be cached more aggressively @@ -105,13 +105,13 @@ async def execute(self, request: GetAllSkillsRequest) -> GetAllSkillsResponse: async def execute(self, request: TRequest) -> TResponse: """ Execute the query with the given parameters. - + Args: request: The query parameters - + Returns: The query result - + Notes: - Should not modify any state - Should be fast and cacheable @@ -123,20 +123,20 @@ async def execute(self, request: TRequest) -> TResponse: class ICommandUseCase(ABC, Generic[TRequest, TResponse]): """ Command Use Case Interface. - + Specialized interface for write operations (commands). Follows CQRS (Command Query Responsibility Segregation) pattern. - + Type Parameters: TRequest: The command data TResponse: The command result (may be void/success status) - + Usage: class CreateProfileCommand(ICommandUseCase[CreateProfileRequest, CreateProfileResponse]): async def execute(self, request: CreateProfileRequest) -> CreateProfileResponse: # Write operation ... - + Notes: - Commands modify state - Should validate business rules @@ -148,16 +148,16 @@ async def execute(self, request: CreateProfileRequest) -> CreateProfileResponse: async def execute(self, request: TRequest) -> TResponse: """ Execute the command with the given data. - + Args: request: The command data - + Returns: The command result - + Raises: DomainError: For business rule violations - + Notes: - Should validate all business rules - Should be transactional when needed @@ -169,13 +169,13 @@ async def execute(self, request: TRequest) -> TResponse: class IValidator(ABC, Generic[TRequest]): """ Validator Interface. - + Defines the contract for request validation. Can be used by use cases to validate input before processing. - + Type Parameters: TRequest: The request type to validate - + Usage: class CreateProfileValidator(IValidator[CreateProfileRequest]): def validate(self, request: CreateProfileRequest) -> List[str]: @@ -189,16 +189,16 @@ def validate(self, request: CreateProfileRequest) -> List[str]: def validate(self, request: TRequest) -> list[str]: """ Validate the request. - + Args: request: The request to validate - + Returns: List of error messages (empty if valid) - + Notes: - Should check all validation rules - Should return clear error messages - Should not throw exceptions (return errors instead) """ - pass \ No newline at end of file + pass diff --git a/app/shared/shared_exceptions/__init__.py b/app/shared/shared_exceptions/__init__.py index 5b44336..d836217 100644 --- a/app/shared/shared_exceptions/__init__.py +++ b/app/shared/shared_exceptions/__init__.py @@ -17,6 +17,9 @@ """ +from typing import Any + + class ApplicationException(Exception): """ Base exception for application layer errors. @@ -24,7 +27,7 @@ class ApplicationException(Exception): All application-level exceptions should inherit from this. """ - def __init__(self, message: str, details: dict = None): + def __init__(self, message: str, details: dict[str, Any] | None = None): self.message = message self.details = details or {} super().__init__(self.message) @@ -99,7 +102,7 @@ class BusinessRuleViolationException(ApplicationException): Maps to HTTP 400 (Bad Request). """ - def __init__(self, rule: str, details: dict = None): + def __init__(self, rule: str, details: dict[str, Any] | None = None): message = f"Business rule violation: {rule}" super().__init__(message, details) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..79d6bfb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,145 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "portfolio-backend" +version = "1.0.0" +description = "Portfolio Backend API with FastAPI and MongoDB" +requires-python = ">=3.12" + +[tool.black] +line-length = 88 +target-version = ['py312', 'py313'] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.venv + | venv + | \.pytest_cache + | \.mypy_cache + | __pycache__ + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +line_length = 88 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +skip_glob = ["venv/*", ".venv/*", "__pycache__/*"] + +[tool.ruff] +line-length = 88 +target-version = "py312" +exclude = [ + ".git", + ".venv", + "venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + "build", + "dist", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by black) + "B008", # do not perform function calls in argument defaults + "C901", # too complex +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] +"tests/*" = ["ARG", "S101"] + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +disallow_incomplete_defs = false +check_untyped_defs = true +disallow_untyped_calls = false +disallow_untyped_decorators = false +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +follow_imports = "normal" +ignore_missing_imports = true +strict_optional = true +plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = "tests.*" +ignore_errors = true + +[tool.pytest.ini_options] +minversion = "7.0" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--strict-config", + "--tb=short", +] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "e2e: End-to-end tests", + "slow: Slow running tests", +] +asyncio_mode = "auto" + +[tool.coverage.run] +source = ["app"] +omit = [ + "*/tests/*", + "*/venv/*", + "*/.venv/*", + "*/migrations/*", +] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] + +[tool.coverage.html] +directory = "htmlcov" + +[tool.bandit] +exclude_dirs = ["tests", "venv", ".venv"] +skips = ["B101", "B601"] diff --git a/requirements-dev.txt b/requirements-dev.txt index a8593ee..1da6bdc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,15 +7,15 @@ # ========================================== # TESTING # ========================================== -pytest==8.3.4 -pytest-asyncio==0.24.0 +pytest==9.0.2 +pytest-asyncio==1.3.0 pytest-cov==6.0.0 pytest-mock==3.14.0 pytest-timeout==2.3.1 pytest-xdist==3.6.1 # Parallel test execution # HTTP Testing -httpx==0.27.2 +httpx==0.28.1 # Faker for test data generation faker==33.1.0 @@ -24,7 +24,7 @@ faker==33.1.0 # CODE QUALITY & LINTING # ========================================== # Formatter -black==24.10.0 +black==26.1.0 isort==5.13.2 # Linter diff --git a/scripts/validate_business_rules.py b/scripts/validate_business_rules.py index 9a7b4ef..80f91fd 100644 --- a/scripts/validate_business_rules.py +++ b/scripts/validate_business_rules.py @@ -6,9 +6,8 @@ """ import sys -import os -from pathlib import Path from datetime import datetime +from pathlib import Path # ========================================================= # ADD PROJECT ROOT TO PYTHONPATH @@ -20,20 +19,26 @@ # ========================================================= # IMPORT DOMAIN MODULES # ========================================================= -from app.domain.entities import ( - Profile, WorkExperience, Skill, Education, Project, - Certification, AdditionalTraining, ContactInformation, - ContactMessage, SocialNetwork, Tool -) -from app.domain.value_objects import ( - DateRange, Email, Phone, SkillLevel, ContactInfo +from app.domain.entities import ( # noqa: E402 + ContactMessage, + Education, + Profile, + Skill, + WorkExperience, ) -from app.domain.exceptions import ( - EmptyFieldError, InvalidLengthError, InvalidEmailError, - InvalidPhoneError, InvalidURLError, InvalidDateRangeError, - InvalidSkillLevelError, DuplicateValueError, - InvalidRoleError, InvalidNameError, InvalidInstitutionError +from app.domain.exceptions import ( # noqa: E402 + EmptyFieldError, + InvalidDateRangeError, + InvalidEmailError, + InvalidInstitutionError, + InvalidLengthError, + InvalidNameError, + InvalidPhoneError, + InvalidRoleError, + InvalidSkillLevelError, + InvalidURLError, ) +from app.domain.value_objects import DateRange, Email, Phone, SkillLevel # noqa: E402 # ========================================================= # TESTS diff --git a/scripts/validate_imports.py b/scripts/validate_imports.py index 1ba7a98..4dd35a6 100644 --- a/scripts/validate_imports.py +++ b/scripts/validate_imports.py @@ -3,8 +3,8 @@ This ensures there are no circular import issues. """ -import os -import sys +import os +import sys # Add project root to PYTHONPATH CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -13,70 +13,26 @@ # Test 1: Import interfaces module print("Testing interfaces imports...") -from app.shared.interfaces import ( - ICommandUseCase, - IContactMessageRepository, - IDTOMapper, - IMapper, - IOrderedRepository, - IProfileRepository, - IQueryUseCase, - IRepository, - ISocialNetworkRepository, - IUniqueNameRepository, - IUseCase, - IValidator, - IValueObjectMapper, -) print("✓ All interface imports successful") # Test 2: Import type aliases print("\nTesting type alias imports...") -from app.shared.interfaces import ( - AdditionalTrainingRepository, - CertificationRepository, - ContactInformationRepository, - ContactMessageRepository, - EducationRepository, - ProfileRepository, - ProjectRepository, - SkillRepository, - SocialNetworkRepository, - ToolRepository, - WorkExperienceRepository, -) print("✓ All type alias imports successful") # Test 3: Import shared module print("\nTesting shared module imports...") -from app.shared import ( - IMapper as Map, -) -from app.shared import ( - IRepository as Repo, -) -from app.shared import ( - IUseCase as UC, -) print("✓ Shared module imports successful") # Test 4: Import exceptions print("\nTesting exception imports...") -from app.shared.shared_exceptions import ( - ApplicationException, - DuplicateException, - NotFoundException, - ValidationException, -) print("✓ Exception imports successful") # Test 5: Import types print("\nTesting type imports...") -from app.shared.types import ID, Document, Timestamp print("✓ Type imports successful") diff --git a/tests/__init__.py b/tests/__init__.py index 2ed52de..28e1fda 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -7,4 +7,4 @@ - integration/: Tests de integración (API, base de datos) - e2e/: Tests end-to-end (flujos completos) - fixtures/: Datos de prueba reutilizables -""" \ No newline at end of file +""" diff --git a/tests/conftest.py b/tests/conftest.py index 4f0a25c..377b6d0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,18 +3,21 @@ Configuración global de pytest. Este archivo se ejecuta antes de todos los tests. """ -import pytest -from typing import Generator + +from collections.abc import AsyncGenerator from datetime import datetime, timedelta + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient from motor.motor_asyncio import AsyncIOMotorClient -from httpx import AsyncClient -from app.main import app from app.config.settings import Settings - +from app.main import app # ==================== FIXTURES DE CONFIGURACIÓN ==================== + @pytest.fixture(scope="session") def test_settings() -> Settings: """ @@ -26,29 +29,33 @@ def test_settings() -> Settings: DEBUG=True, MONGODB_URL="mongodb://localhost:27017", MONGODB_DB_NAME="portfolio_test_db", - SECRET_KEY="test-secret-key-never-use-in-production" + SECRET_KEY="test-secret-key-never-use-in-production", ) # ==================== FIXTURES DE CLIENTE HTTP ==================== -@pytest.fixture -async def client() -> Generator: + +@pytest_asyncio.fixture +async def client() -> AsyncGenerator[AsyncClient, None]: """ Cliente HTTP asíncrono para testear endpoints de FastAPI. - + Ejemplo de uso: async def test_endpoint(client): response = await client.get("/api/v1/health") assert response.status_code == 200 """ - async with AsyncClient(app=app, base_url="http://test") as ac: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: yield ac # ==================== FIXTURES DE BASE DE DATOS ==================== -@pytest.fixture(scope="session") + +@pytest_asyncio.fixture(scope="session") async def mongodb_client(test_settings) -> AsyncIOMotorClient: """ Cliente de MongoDB para tests. @@ -59,19 +66,19 @@ async def mongodb_client(test_settings) -> AsyncIOMotorClient: client.close() -@pytest.fixture +@pytest_asyncio.fixture async def test_db(mongodb_client, test_settings): """ Base de datos de test limpia. Se limpia antes y después de cada test. """ db = mongodb_client[test_settings.MONGODB_DB_NAME] - + # Limpiar antes del test await _clean_database(db) - + yield db - + # Limpiar después del test await _clean_database(db) @@ -85,6 +92,7 @@ async def _clean_database(db): # ==================== FIXTURES DE DATOS DE PRUEBA ==================== + # Datetime fixtures @pytest.fixture def today() -> datetime: @@ -144,7 +152,7 @@ def sample_profile_data(): "full_name": "Test User", "headline": "Test Developer", "about": "Test description", - "location": "Test City" + "location": "Test City", } @@ -155,20 +163,22 @@ def sample_skill_data(): "name": "Python", "level": "expert", "category": "backend", - "order_index": 0 + "order_index": 0, } # ==================== HOOKS DE PYTEST ==================== + def pytest_configure(config): """ Hook que se ejecuta al inicio de pytest. Aquí puedes configurar variables de entorno, etc. """ import os + os.environ["ENVIRONMENT"] = "test" - + def pytest_collection_modifyitems(items): """ @@ -178,4 +188,4 @@ def pytest_collection_modifyitems(items): for item in items: # Añadir marcador 'asyncio' automáticamente a tests async if "asyncio" in item.keywords: - item.add_marker(pytest.mark.asyncio) \ No newline at end of file + item.add_marker(pytest.mark.asyncio) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 7dc922c..ba8806d 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,2 +1,2 @@ # tests/integration/__init__.py -"""Tests de integración""" \ No newline at end of file +"""Tests de integración""" diff --git a/tests/integration/test_health.py b/tests/integration/test_health.py index e8a3659..3582d50 100644 --- a/tests/integration/test_health.py +++ b/tests/integration/test_health.py @@ -2,6 +2,7 @@ """ Tests de integración para el endpoint de health check. """ + import pytest from httpx import AsyncClient @@ -11,9 +12,9 @@ async def test_health_check(client: AsyncClient): """Test: Health check endpoint retorna 200""" response = await client.get("/api/v1/health") - + assert response.status_code == 200 - + data = response.json() assert data["status"] == "ok" assert "service" in data @@ -26,9 +27,9 @@ async def test_health_check(client: AsyncClient): async def test_root_endpoint(client: AsyncClient): """Test: Root endpoint retorna información básica""" response = await client.get("/") - + assert response.status_code == 200 - + data = response.json() assert "message" in data - assert "version" in data \ No newline at end of file + assert "version" in data diff --git a/tests/test_connection.py b/tests/test_connection.py index a1f8557..7b253f3 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,6 +1,7 @@ -from pymongo import MongoClient import os + from dotenv import load_dotenv +from pymongo import MongoClient # Cargar variables de entorno load_dotenv() @@ -11,11 +12,11 @@ try: # Intentar conectar client = MongoClient(MONGODB_URL) - + # Listar bases de datos print("✅ Conexión exitosa!") print("Bases de datos disponibles:") print(client.list_database_names()) - + except Exception as e: - print(f"❌ Error de conexión: {e}") \ No newline at end of file + print(f"❌ Error de conexión: {e}") diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index a5d3712..c09e5fb 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1,2 +1,2 @@ # tests/unit/__init__.py -"""Tests unitarios""" \ No newline at end of file +"""Tests unitarios""" diff --git a/tests/unit/domain/entities/test_contact_message.py b/tests/unit/domain/entities/test_contact_message.py index e6dec9d..2de91f1 100644 --- a/tests/unit/domain/entities/test_contact_message.py +++ b/tests/unit/domain/entities/test_contact_message.py @@ -11,24 +11,25 @@ - Append-only nature (no updates after creation) """ -import pytest -from datetime import datetime import uuid +from datetime import datetime -from app.domain.entities.contact_message import ContactMessage, VALID_MESSAGE_STATUSES +import pytest + +from app.domain.entities.contact_message import VALID_MESSAGE_STATUSES, ContactMessage from app.domain.exceptions import ( + DomainError, EmptyFieldError, InvalidEmailError, InvalidLengthError, InvalidNameError, - DomainError, ) - # ========================================== # VALID CONTACT MESSAGE CREATION TESTS # ========================================== + class TestContactMessageCreation: """Tests for valid ContactMessage entity creation.""" @@ -43,7 +44,10 @@ def test_create_with_all_required_fields(self, valid_email): assert message.id is not None assert message.name == "John Doe" assert message.email == valid_email - assert message.message == "This is a test message with enough characters to be valid." + assert ( + message.message + == "This is a test message with enough characters to be valid." + ) assert message.status == "pending" assert message.created_at is not None assert message.read_at is None @@ -95,6 +99,7 @@ def test_create_sets_pending_status(self, valid_email): # NAME VALIDATION TESTS # ========================================== + class TestContactMessageNameValidation: """Tests for ContactMessage name validation.""" @@ -147,6 +152,7 @@ def test_max_length_name_is_valid(self, valid_email): # EMAIL VALIDATION TESTS # ========================================== + class TestContactMessageEmailValidation: """Tests for ContactMessage email validation.""" @@ -229,6 +235,7 @@ def test_valid_email_formats(self): # MESSAGE VALIDATION TESTS # ========================================== + class TestContactMessageMessageValidation: """Tests for ContactMessage message content validation.""" @@ -309,6 +316,7 @@ def test_max_length_message_is_valid(self, valid_email): # STATUS VALIDATION TESTS # ========================================== + class TestContactMessageStatusValidation: """Tests for ContactMessage status validation.""" @@ -342,6 +350,7 @@ def test_valid_statuses_are_accepted(self, valid_email): # STATUS TRANSITION TESTS # ========================================== + class TestContactMessageStatusTransitions: """Tests for ContactMessage status transitions.""" @@ -377,6 +386,7 @@ def test_mark_as_read_when_already_read_does_nothing(self, valid_email): # Try to mark as read again import time + time.sleep(0.01) message.mark_as_read() @@ -418,6 +428,7 @@ def test_mark_as_replied_from_read(self, valid_email): original_read_at = message.read_at import time + time.sleep(0.01) before = datetime.utcnow() @@ -443,6 +454,7 @@ def test_mark_as_replied_when_already_replied_does_nothing(self, valid_email): # Try to mark as replied again import time + time.sleep(0.01) message.mark_as_replied() @@ -455,6 +467,7 @@ def test_mark_as_replied_when_already_replied_does_nothing(self, valid_email): # QUERY METHODS TESTS # ========================================== + class TestContactMessageQueryMethods: """Tests for ContactMessage query methods.""" @@ -559,6 +572,7 @@ def test_is_replied_returns_true_for_replied_status(self, valid_email): # ENTITY REPRESENTATION TESTS # ========================================== + class TestContactMessageRepresentation: """Tests for ContactMessage entity representation.""" @@ -582,6 +596,7 @@ def test_repr_contains_key_information(self, valid_email): # EDGE CASES TESTS # ========================================== + class TestContactMessageEdgeCases: """Tests for ContactMessage edge cases.""" diff --git a/tests/unit/domain/entities/test_education.py b/tests/unit/domain/entities/test_education.py index 3130446..4559f86 100644 --- a/tests/unit/domain/entities/test_education.py +++ b/tests/unit/domain/entities/test_education.py @@ -10,9 +10,10 @@ - Temporal coherence """ -import pytest -from datetime import datetime import uuid +from datetime import datetime + +import pytest from app.domain.entities.education import Education from app.domain.exceptions import ( @@ -23,11 +24,11 @@ InvalidOrderIndexError, ) - # ========================================== # VALID EDUCATION CREATION TESTS # ========================================== + class TestEducationCreation: """Tests for valid Education entity creation.""" @@ -128,6 +129,7 @@ def test_create_with_order_index_zero(self, profile_id, today): # FIELD VALIDATION TESTS # ========================================== + class TestEducationFieldValidation: """Tests for Education field validation.""" @@ -366,10 +368,13 @@ def test_negative_order_index_raises_error(self, profile_id, today): # DATE VALIDATION TESTS # ========================================== + class TestEducationDateValidation: """Tests for Education date validation.""" - def test_end_date_before_start_date_raises_error(self, profile_id, today, yesterday): + def test_end_date_before_start_date_raises_error( + self, profile_id, today, yesterday + ): """Should raise InvalidDateRangeError when end_date is before start_date.""" with pytest.raises(InvalidDateRangeError): Education.create( @@ -428,6 +433,7 @@ def test_no_end_date_is_valid(self, profile_id, today): # UPDATE OPERATIONS TESTS # ========================================== + class TestEducationUpdateOperations: """Tests for Education update operations.""" @@ -587,6 +593,7 @@ def test_update_info_marks_as_updated(self, profile_id, today): # Small delay to ensure timestamp changes import time + time.sleep(0.01) education.update_info(description="Updated description") @@ -637,6 +644,7 @@ def test_update_order_marks_as_updated(self, profile_id, today): # Small delay to ensure timestamp changes import time + time.sleep(0.01) education.update_order(1) @@ -648,6 +656,7 @@ def test_update_order_marks_as_updated(self, profile_id, today): # QUERY METHODS TESTS # ========================================== + class TestEducationQueryMethods: """Tests for Education query methods.""" @@ -664,7 +673,9 @@ def test_is_ongoing_returns_true_when_no_end_date(self, profile_id, today): assert education.is_ongoing() is True - def test_is_ongoing_returns_false_when_has_end_date(self, profile_id, yesterday, tomorrow): + def test_is_ongoing_returns_false_when_has_end_date( + self, profile_id, yesterday, tomorrow + ): """Should return False for is_ongoing when end_date exists.""" education = Education.create( profile_id=profile_id, @@ -683,6 +694,7 @@ def test_is_ongoing_returns_false_when_has_end_date(self, profile_id, yesterday, # ENTITY REPRESENTATION TESTS # ========================================== + class TestEducationRepresentation: """Tests for Education entity representation.""" @@ -709,6 +721,7 @@ def test_repr_contains_key_information(self, profile_id, today): # EDGE CASES TESTS # ========================================== + class TestEducationEdgeCases: """Tests for Education edge cases.""" diff --git a/tests/unit/domain/entities/test_profile.py b/tests/unit/domain/entities/test_profile.py index 6e145ef..ae45641 100644 --- a/tests/unit/domain/entities/test_profile.py +++ b/tests/unit/domain/entities/test_profile.py @@ -3,6 +3,7 @@ """ import pytest + from app.domain.entities.profile import Profile from app.domain.exceptions import EmptyFieldError, InvalidLengthError, InvalidURLError @@ -13,11 +14,8 @@ class TestProfileCreation: def test_create_with_required_fields(self): """Should create profile with required fields.""" - profile = Profile.create( - name="John Doe", - headline="Software Engineer" - ) - + profile = Profile.create(name="John Doe", headline="Software Engineer") + assert profile.name == "John Doe" assert profile.headline == "Software Engineer" assert profile.id is not None @@ -29,9 +27,9 @@ def test_create_with_optional_fields(self, valid_url): headline="Engineer", bio="Developer", location="Barcelona", - avatar_url=valid_url + avatar_url=valid_url, ) - + assert profile.bio == "Developer" assert profile.location == "Barcelona" @@ -74,16 +72,16 @@ class TestProfileUpdate: def test_update_basic_info(self): """Should update profile info.""" profile = Profile.create(name="John", headline="Engineer") - + profile.update_basic_info(name="Jane", headline="Senior Engineer") - + assert profile.name == "Jane" assert profile.headline == "Senior Engineer" def test_update_avatar(self, valid_url): """Should update avatar.""" profile = Profile.create(name="John", headline="Engineer") - + profile.update_avatar(valid_url) - - assert profile.avatar_url == valid_url \ No newline at end of file + + assert profile.avatar_url == valid_url diff --git a/tests/unit/domain/entities/test_skill.py b/tests/unit/domain/entities/test_skill.py index fe57aa3..e246107 100644 --- a/tests/unit/domain/entities/test_skill.py +++ b/tests/unit/domain/entities/test_skill.py @@ -3,8 +3,14 @@ """ import pytest + from app.domain.entities.skill import Skill -from app.domain.exceptions import EmptyFieldError, InvalidLengthError, InvalidSkillLevelError +from app.domain.exceptions import ( + InvalidCategoryError, + InvalidLengthError, + InvalidNameError, + InvalidSkillLevelError, +) @pytest.mark.entity @@ -14,12 +20,9 @@ class TestSkillCreation: def test_create_with_required_fields(self, profile_id): """Should create skill with required fields.""" skill = Skill.create( - profile_id=profile_id, - name="Python", - category="Programming", - order_index=0 + profile_id=profile_id, name="Python", category="Programming", order_index=0 ) - + assert skill.name == "Python" assert skill.category == "Programming" assert skill.order_index == 0 @@ -31,9 +34,9 @@ def test_create_with_level(self, profile_id): name="Python", category="Programming", order_index=0, - level="intermediate" + level="intermediate", ) - + assert skill.level == "intermediate" @@ -44,12 +47,9 @@ class TestSkillValidation: def test_empty_name_raises_error(self, profile_id): """Should raise error for empty name.""" - with pytest.raises(Exception): # InvalidNameError or InvalidLengthError + with pytest.raises((InvalidNameError, InvalidLengthError)): Skill.create( - profile_id=profile_id, - name="", - category="Programming", - order_index=0 + profile_id=profile_id, name="", category="Programming", order_index=0 ) def test_name_too_long_raises_error(self, profile_id): @@ -59,17 +59,14 @@ def test_name_too_long_raises_error(self, profile_id): profile_id=profile_id, name="x" * 51, category="Programming", - order_index=0 + order_index=0, ) def test_empty_category_raises_error(self, profile_id): """Should raise error for empty category.""" - with pytest.raises(Exception): # InvalidCategoryError or InvalidLengthError + with pytest.raises((InvalidCategoryError, InvalidLengthError)): Skill.create( - profile_id=profile_id, - name="Python", - category="", - order_index=0 + profile_id=profile_id, name="Python", category="", order_index=0 ) def test_invalid_level_raises_error(self, profile_id): @@ -80,7 +77,7 @@ def test_invalid_level_raises_error(self, profile_id): name="Python", category="Programming", order_index=0, - level="master" + level="master", ) @@ -91,13 +88,10 @@ class TestSkillUpdate: def test_update_info(self, profile_id): """Should update skill info.""" skill = Skill.create( - profile_id=profile_id, - name="Python", - category="Programming", - order_index=0 + profile_id=profile_id, name="Python", category="Programming", order_index=0 ) - + skill.update_info(name="Advanced Python", level="advanced") - + assert skill.name == "Advanced Python" - assert skill.level == "advanced" \ No newline at end of file + assert skill.level == "advanced" diff --git a/tests/unit/domain/test_business_rules.py b/tests/unit/domain/test_business_rules.py index 884dee7..5145ae0 100644 --- a/tests/unit/domain/test_business_rules.py +++ b/tests/unit/domain/test_business_rules.py @@ -14,38 +14,40 @@ - Immutability constraints """ -import pytest from datetime import datetime, timedelta +import pytest + +from app.domain.entities.contact_message import ContactMessage +from app.domain.entities.education import Education from app.domain.entities.profile import Profile -from app.domain.entities.work_experience import WorkExperience from app.domain.entities.skill import Skill -from app.domain.entities.education import Education -from app.domain.entities.contact_message import ContactMessage -from app.domain.value_objects.email import Email -from app.domain.value_objects.phone import Phone -from app.domain.value_objects.skill_level import SkillLevel -from app.domain.value_objects.date_range import DateRange +from app.domain.entities.work_experience import WorkExperience from app.domain.exceptions import ( EmptyFieldError, - InvalidLengthError, + InvalidCategoryError, + InvalidCompanyError, + InvalidDateRangeError, InvalidEmailError, + InvalidInstitutionError, + InvalidLengthError, + InvalidNameError, + InvalidOrderIndexError, InvalidPhoneError, - InvalidURLError, - InvalidDateRangeError, - InvalidSkillLevelError, InvalidRoleError, - InvalidNameError, - InvalidInstitutionError, - InvalidCompanyError, - InvalidCategoryError, + InvalidSkillLevelError, + InvalidURLError, ) - +from app.domain.value_objects.date_range import DateRange +from app.domain.value_objects.email import Email +from app.domain.value_objects.phone import Phone +from app.domain.value_objects.skill_level import SkillLevel # ========================================== # PROFILE BUSINESS RULES TESTS # ========================================== + class TestProfileBusinessRules: """Tests for Profile entity business rules.""" @@ -74,21 +76,13 @@ def test_rb_p03_bio_max_length_enforced(self): long_bio = "x" * 1001 with pytest.raises(InvalidLengthError): - Profile.create( - name="John Doe", - headline="Test Headline", - bio=long_bio - ) + Profile.create(name="John Doe", headline="Test Headline", bio=long_bio) def test_rb_p03_bio_max_length_accepted(self): """RB-P03: Profile bio at exactly max length is accepted.""" max_bio = "x" * 1000 - profile = Profile.create( - name="John Doe", - headline="Test Headline", - bio=max_bio - ) + profile = Profile.create(name="John Doe", headline="Test Headline", bio=max_bio) assert len(profile.bio) == 1000 @@ -96,9 +90,7 @@ def test_rb_p05_avatar_url_must_be_valid(self): """RB-P05: Avatar URL must be valid format.""" with pytest.raises(InvalidURLError): Profile.create( - name="John Doe", - headline="Test Headline", - avatar_url="not-a-url" + name="John Doe", headline="Test Headline", avatar_url="not-a-url" ) def test_rb_p05_avatar_url_valid_accepted(self): @@ -106,7 +98,7 @@ def test_rb_p05_avatar_url_valid_accepted(self): profile = Profile.create( name="John Doe", headline="Test Headline", - avatar_url="https://example.com/avatar.jpg" + avatar_url="https://example.com/avatar.jpg", ) assert profile.avatar_url == "https://example.com/avatar.jpg" @@ -116,6 +108,7 @@ def test_rb_p05_avatar_url_valid_accepted(self): # WORK EXPERIENCE BUSINESS RULES TESTS # ========================================== + class TestWorkExperienceBusinessRules: """Tests for WorkExperience entity business rules.""" @@ -127,7 +120,7 @@ def test_rb_w01_role_is_required(self, profile_id, today): role="", company="Test Company", start_date=today, - order_index=0 + order_index=0, ) def test_rb_w01_role_not_whitespace_only(self, profile_id, today): @@ -138,7 +131,7 @@ def test_rb_w01_role_not_whitespace_only(self, profile_id, today): role=" ", company="Test Company", start_date=today, - order_index=0 + order_index=0, ) def test_rb_w02_company_is_required(self, profile_id, today): @@ -149,10 +142,12 @@ def test_rb_w02_company_is_required(self, profile_id, today): role="Software Engineer", company="", start_date=today, - order_index=0 + order_index=0, ) - def test_rb_w05_end_date_must_be_after_start_date(self, profile_id, yesterday, today): + def test_rb_w05_end_date_must_be_after_start_date( + self, profile_id, yesterday, today + ): """RB-W05: End date must be after start date.""" with pytest.raises(InvalidDateRangeError): WorkExperience.create( @@ -161,7 +156,7 @@ def test_rb_w05_end_date_must_be_after_start_date(self, profile_id, yesterday, t company="Test Company", start_date=today, end_date=yesterday, - order_index=0 + order_index=0, ) def test_rb_w05_end_date_cannot_equal_start_date(self, profile_id, today): @@ -173,7 +168,7 @@ def test_rb_w05_end_date_cannot_equal_start_date(self, profile_id, today): company="Test Company", start_date=today, end_date=today, - order_index=0 + order_index=0, ) def test_rb_w05_valid_date_range_accepted(self, profile_id, yesterday, tomorrow): @@ -184,7 +179,7 @@ def test_rb_w05_valid_date_range_accepted(self, profile_id, yesterday, tomorrow) company="Test Company", start_date=yesterday, end_date=tomorrow, - order_index=0 + order_index=0, ) assert experience.start_date == yesterday @@ -195,6 +190,7 @@ def test_rb_w05_valid_date_range_accepted(self, profile_id, yesterday, tomorrow) # SKILL BUSINESS RULES TESTS # ========================================== + class TestSkillBusinessRules: """Tests for Skill entity business rules.""" @@ -202,30 +198,21 @@ def test_rb_s01_name_is_required(self, profile_id): """RB-S01: Skill name is required.""" with pytest.raises((EmptyFieldError, InvalidNameError)): Skill.create( - profile_id=profile_id, - name="", - category="backend", - order_index=0 + profile_id=profile_id, name="", category="backend", order_index=0 ) def test_rb_s01_name_not_whitespace_only(self, profile_id): """RB-S01: Skill name cannot be whitespace only.""" with pytest.raises((EmptyFieldError, InvalidNameError)): Skill.create( - profile_id=profile_id, - name=" ", - category="backend", - order_index=0 + profile_id=profile_id, name=" ", category="backend", order_index=0 ) def test_rb_s02_category_is_required(self, profile_id): """RB-S02: Skill category is required.""" with pytest.raises((EmptyFieldError, InvalidCategoryError)): Skill.create( - profile_id=profile_id, - name="Python", - category="", - order_index=0 + profile_id=profile_id, name="Python", category="", order_index=0 ) def test_rb_s04_level_must_be_valid(self, profile_id): @@ -236,7 +223,7 @@ def test_rb_s04_level_must_be_valid(self, profile_id): name="Python", category="backend", level="invalid_level", - order_index=0 + order_index=0, ) def test_rb_s04_valid_levels_accepted(self, profile_id): @@ -249,7 +236,7 @@ def test_rb_s04_valid_levels_accepted(self, profile_id): name=f"Python-{level}", category="backend", level=level, - order_index=0 + order_index=0, ) # Skill stores level as string directly assert skill.level == level @@ -259,6 +246,7 @@ def test_rb_s04_valid_levels_accepted(self, profile_id): # EDUCATION BUSINESS RULES TESTS # ========================================== + class TestEducationBusinessRules: """Tests for Education entity business rules.""" @@ -271,7 +259,7 @@ def test_rb_e01_institution_is_required(self, profile_id, today): degree="Bachelor of Science", field="Computer Science", start_date=today, - order_index=0 + order_index=0, ) def test_rb_e01_institution_not_whitespace_only(self, profile_id, today): @@ -283,7 +271,7 @@ def test_rb_e01_institution_not_whitespace_only(self, profile_id, today): degree="Bachelor of Science", field="Computer Science", start_date=today, - order_index=0 + order_index=0, ) def test_rb_e02_degree_is_required(self, profile_id, today): @@ -295,7 +283,7 @@ def test_rb_e02_degree_is_required(self, profile_id, today): degree="", field="Computer Science", start_date=today, - order_index=0 + order_index=0, ) def test_rb_e03_field_is_required(self, profile_id, today): @@ -307,10 +295,12 @@ def test_rb_e03_field_is_required(self, profile_id, today): degree="Bachelor of Science", field="", start_date=today, - order_index=0 + order_index=0, ) - def test_rb_e05_end_date_must_be_after_start_date(self, profile_id, yesterday, today): + def test_rb_e05_end_date_must_be_after_start_date( + self, profile_id, yesterday, today + ): """RB-E05: End date must be after start date.""" with pytest.raises(InvalidDateRangeError): Education.create( @@ -320,7 +310,7 @@ def test_rb_e05_end_date_must_be_after_start_date(self, profile_id, yesterday, t field="Computer Science", start_date=today, end_date=yesterday, - order_index=0 + order_index=0, ) def test_rb_e05_end_date_cannot_equal_start_date(self, profile_id, today): @@ -333,7 +323,7 @@ def test_rb_e05_end_date_cannot_equal_start_date(self, profile_id, today): field="Computer Science", start_date=today, end_date=today, - order_index=0 + order_index=0, ) def test_rb_e05_valid_date_range_accepted(self, profile_id, yesterday, tomorrow): @@ -345,7 +335,7 @@ def test_rb_e05_valid_date_range_accepted(self, profile_id, yesterday, tomorrow) field="Computer Science", start_date=yesterday, end_date=tomorrow, - order_index=0 + order_index=0, ) assert education.start_date == yesterday @@ -356,6 +346,7 @@ def test_rb_e05_valid_date_range_accepted(self, profile_id, yesterday, tomorrow) # CONTACT MESSAGE BUSINESS RULES TESTS # ========================================== + class TestContactMessageBusinessRules: """Tests for ContactMessage entity business rules.""" @@ -365,7 +356,7 @@ def test_rb_cm01_name_is_required(self, valid_email): ContactMessage.create( name="", email=valid_email, - message="This is a test message with enough characters" + message="This is a test message with enough characters", ) def test_rb_cm01_name_not_whitespace_only(self, valid_email): @@ -374,7 +365,7 @@ def test_rb_cm01_name_not_whitespace_only(self, valid_email): ContactMessage.create( name=" ", email=valid_email, - message="This is a test message with enough characters" + message="This is a test message with enough characters", ) def test_rb_cm02_email_is_required(self): @@ -383,7 +374,7 @@ def test_rb_cm02_email_is_required(self): ContactMessage.create( name="John Doe", email="", - message="This is a test message with enough characters" + message="This is a test message with enough characters", ) def test_rb_cm02_email_must_be_valid_format(self): @@ -392,7 +383,7 @@ def test_rb_cm02_email_must_be_valid_format(self): ContactMessage.create( name="John Doe", email="invalid-email", - message="This is a test message with enough characters" + message="This is a test message with enough characters", ) def test_rb_cm02_valid_email_accepted(self): @@ -400,7 +391,7 @@ def test_rb_cm02_valid_email_accepted(self): message = ContactMessage.create( name="John Doe", email="test@example.com", - message="This is a test message with enough characters" + message="This is a test message with enough characters", ) assert message.email == "test@example.com" @@ -408,20 +399,12 @@ def test_rb_cm02_valid_email_accepted(self): def test_rb_cm03_message_is_required(self, valid_email): """RB-CM03: Contact message content is required.""" with pytest.raises(EmptyFieldError): - ContactMessage.create( - name="John Doe", - email=valid_email, - message="" - ) + ContactMessage.create(name="John Doe", email=valid_email, message="") def test_rb_cm03_message_min_length_enforced(self, valid_email): """RB-CM03: Contact message has minimum length of 10 characters.""" with pytest.raises(InvalidLengthError): - ContactMessage.create( - name="John Doe", - email=valid_email, - message="Short" - ) + ContactMessage.create(name="John Doe", email=valid_email, message="Short") def test_rb_cm03_message_max_length_enforced(self, valid_email): """RB-CM03: Contact message has maximum length of 2000 characters.""" @@ -429,9 +412,7 @@ def test_rb_cm03_message_max_length_enforced(self, valid_email): with pytest.raises(InvalidLengthError): ContactMessage.create( - name="John Doe", - email=valid_email, - message=long_message + name="John Doe", email=valid_email, message=long_message ) def test_rb_cm03_valid_message_length_accepted(self, valid_email): @@ -439,7 +420,7 @@ def test_rb_cm03_valid_message_length_accepted(self, valid_email): message = ContactMessage.create( name="John Doe", email=valid_email, - message="This is a valid message with enough characters to pass validation" + message="This is a valid message with enough characters to pass validation", ) assert len(message.message) >= 10 @@ -450,6 +431,7 @@ def test_rb_cm03_valid_message_length_accepted(self, valid_email): # VALUE OBJECT BUSINESS RULES TESTS # ========================================== + class TestValueObjectBusinessRules: """Tests for Value Object business rules.""" @@ -544,6 +526,7 @@ def test_vr_dr02_valid_date_range_accepted(self): # IMMUTABILITY CONSTRAINTS TESTS # ========================================== + class TestImmutabilityConstraints: """Tests for immutability constraints on value objects.""" @@ -589,49 +572,44 @@ def test_date_range_end_date_immutable(self): # CROSS-CUTTING CONCERNS TESTS # ========================================== + class TestCrossCuttingBusinessRules: """Tests for cross-cutting business rules.""" def test_order_index_cannot_be_negative_skill(self, profile_id): """Order index must be non-negative for Skill.""" - with pytest.raises(Exception): # Could be InvalidOrderIndexError + with pytest.raises(InvalidOrderIndexError): Skill.create( - profile_id=profile_id, - name="Python", - category="backend", - order_index=-1 + profile_id=profile_id, name="Python", category="backend", order_index=-1 ) def test_order_index_cannot_be_negative_education(self, profile_id, today): """Order index must be non-negative for Education.""" - with pytest.raises(Exception): # Could be InvalidOrderIndexError + with pytest.raises(InvalidOrderIndexError): Education.create( profile_id=profile_id, institution="Universidad Complutense", degree="Bachelor of Science", field="Computer Science", start_date=today, - order_index=-1 + order_index=-1, ) def test_order_index_cannot_be_negative_work_experience(self, profile_id, today): """Order index must be non-negative for WorkExperience.""" - with pytest.raises(Exception): # Could be InvalidOrderIndexError + with pytest.raises(InvalidOrderIndexError): WorkExperience.create( profile_id=profile_id, role="Software Engineer", company="Test Company", start_date=today, - order_index=-1 + order_index=-1, ) def test_order_index_zero_is_valid(self, profile_id): """Order index of 0 should be valid (first position).""" skill = Skill.create( - profile_id=profile_id, - name="Python", - category="backend", - order_index=0 + profile_id=profile_id, name="Python", category="backend", order_index=0 ) assert skill.order_index == 0 @@ -641,6 +619,7 @@ def test_order_index_zero_is_valid(self, profile_id): # INTEGRATION TESTS # ========================================== + class TestBusinessRulesIntegration: """Integration tests for business rules working together.""" @@ -651,7 +630,7 @@ def test_profile_with_all_valid_fields(self): headline="Senior Software Engineer", bio="Experienced developer", location="Madrid, Spain", - avatar_url="https://example.com/avatar.jpg" + avatar_url="https://example.com/avatar.jpg", ) assert profile.name == "John Doe" @@ -672,7 +651,7 @@ def test_complete_work_experience_lifecycle(self, profile_id): company="Tech Corp", start_date=yesterday, end_date=tomorrow, - order_index=0 + order_index=0, ) assert experience.role == "Software Engineer" @@ -695,7 +674,7 @@ def test_complete_education_lifecycle(self, profile_id): field="Computer Science", start_date=yesterday, end_date=tomorrow, - order_index=0 + order_index=0, ) assert education.institution == "Universidad Complutense" @@ -710,7 +689,7 @@ def test_contact_message_complete_flow(self): message = ContactMessage.create( name="John Doe", email="john@example.com", - message="This is a valid message with enough characters to pass all validation" + message="This is a valid message with enough characters to pass all validation", ) assert message.status == "pending" diff --git a/tests/unit/domain/value_objects/test_date_range.py b/tests/unit/domain/value_objects/test_date_range.py index 4a4d6e7..f1fa3e2 100644 --- a/tests/unit/domain/value_objects/test_date_range.py +++ b/tests/unit/domain/value_objects/test_date_range.py @@ -2,10 +2,12 @@ Tests for DateRange Value Object. """ -import pytest from datetime import datetime -from app.domain.value_objects.date_range import DateRange + +import pytest + from app.domain.exceptions import InvalidDateRangeError +from app.domain.value_objects.date_range import DateRange @pytest.mark.value_object @@ -15,14 +17,14 @@ class TestDateRangeCreation: def test_create_completed_range(self, yesterday, today): """Should create completed range.""" dr = DateRange.completed(yesterday, today) - + assert dr.start_date == yesterday assert dr.end_date == today def test_create_ongoing_range(self, yesterday): """Should create ongoing range.""" dr = DateRange.ongoing(yesterday) - + assert dr.start_date == yesterday assert dr.end_date is None @@ -44,13 +46,13 @@ class TestDateRangeQueries: def test_is_ongoing_true(self, yesterday): """Should return True for ongoing.""" dr = DateRange.ongoing(yesterday) - + assert dr.is_ongoing() is True def test_is_ongoing_false(self, yesterday, today): """Should return False for completed.""" dr = DateRange.completed(yesterday, today) - + assert dr.is_ongoing() is False def test_duration_days(self): @@ -58,7 +60,7 @@ def test_duration_days(self): start = datetime(2023, 1, 1) end = datetime(2023, 1, 11) dr = DateRange.completed(start, end) - + assert dr.duration_days() == 10 @@ -69,6 +71,6 @@ class TestDateRangeImmutability: def test_immutable(self, yesterday, today): """Should not allow modification.""" dr = DateRange.completed(yesterday, today) - + with pytest.raises(AttributeError): - dr.start_date = datetime.now() \ No newline at end of file + dr.start_date = datetime.now() diff --git a/tests/unit/domain/value_objects/test_email.py b/tests/unit/domain/value_objects/test_email.py index 08c6005..a72600b 100644 --- a/tests/unit/domain/value_objects/test_email.py +++ b/tests/unit/domain/value_objects/test_email.py @@ -3,8 +3,9 @@ """ import pytest + +from app.domain.exceptions import EmptyFieldError, InvalidEmailError from app.domain.value_objects.email import Email -from app.domain.exceptions import InvalidEmailError, EmptyFieldError @pytest.mark.value_object @@ -14,13 +15,13 @@ class TestEmailCreation: def test_create_valid_email(self, valid_email): """Should create email with valid format.""" email = Email.create(valid_email) - + assert email.value == valid_email.lower() def test_email_normalized_to_lowercase(self): """Should normalize to lowercase.""" email = Email.create("TEST@EXAMPLE.COM") - + assert email.value == "test@example.com" @@ -57,24 +58,24 @@ def test_same_emails_equal(self): """Should be equal for same value.""" email1 = Email.create("test@example.com") email2 = Email.create("test@example.com") - + assert email1 == email2 def test_case_insensitive_equality(self): """Should be equal regardless of case.""" email1 = Email.create("TEST@example.com") email2 = Email.create("test@EXAMPLE.com") - + assert email1 == email2 -@pytest.mark.value_object +@pytest.mark.value_object class TestEmailImmutability: """Test Email immutability.""" def test_email_is_immutable(self): """Should not allow modification.""" email = Email.create("test@example.com") - + with pytest.raises(AttributeError): - email.value = "new@example.com" \ No newline at end of file + email.value = "new@example.com" diff --git a/tests/unit/domain/value_objects/test_phone.py b/tests/unit/domain/value_objects/test_phone.py index 555dabc..cb0a75e 100644 --- a/tests/unit/domain/value_objects/test_phone.py +++ b/tests/unit/domain/value_objects/test_phone.py @@ -12,14 +12,14 @@ import pytest -from app.domain.value_objects.phone import Phone from app.domain.exceptions import EmptyFieldError, InvalidPhoneError - +from app.domain.value_objects.phone import Phone # ========================================== # VALID PHONE CREATION TESTS # ========================================== + class TestPhoneCreation: """Tests for valid Phone creation and normalization.""" @@ -77,6 +77,7 @@ def test_create_with_different_country_codes(self): # INVALID PHONE VALIDATION TESTS # ========================================== + class TestPhoneValidation: """Tests for Phone validation errors.""" @@ -134,6 +135,7 @@ def test_special_characters_only_raises_error(self): # FACTORY METHODS TESTS # ========================================== + class TestPhoneFactoryMethods: """Tests for Phone factory methods.""" @@ -178,6 +180,7 @@ def test_from_e164_factory_method(self): # PHONE METHODS TESTS # ========================================== + class TestPhoneMethods: """Tests for Phone methods.""" @@ -250,6 +253,7 @@ def test_repr_returns_debug_format(self): # VALUE OBJECT PROPERTIES TESTS # ========================================== + class TestPhoneValueObjectProperties: """Tests for Phone Value Object properties (equality, immutability, hash).""" @@ -325,6 +329,7 @@ def test_immutability(self): # NORMALIZATION TESTS # ========================================== + class TestPhoneNormalization: """Tests for phone number normalization.""" @@ -374,6 +379,7 @@ def test_normalize_complex_format(self): # EDGE CASES TESTS # ========================================== + class TestPhoneEdgeCases: """Tests for edge cases and boundary conditions.""" diff --git a/tests/unit/domain/value_objects/test_skill_level.py b/tests/unit/domain/value_objects/test_skill_level.py index 34a386b..36d076e 100644 --- a/tests/unit/domain/value_objects/test_skill_level.py +++ b/tests/unit/domain/value_objects/test_skill_level.py @@ -3,8 +3,9 @@ """ import pytest -from app.domain.value_objects.skill_level import SkillLevel + from app.domain.exceptions import InvalidSkillLevelError +from app.domain.value_objects.skill_level import SkillLevel @pytest.mark.value_object @@ -70,4 +71,4 @@ def test_immutable(self): level = SkillLevel.create("basic") with pytest.raises(AttributeError): - level.level = SkillLevel.create("expert").level \ No newline at end of file + level.level = SkillLevel.create("expert").level diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index ff8ccb9..0aa93e2 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -2,7 +2,9 @@ """ Tests para la configuración de la aplicación. """ + import pytest + from app.config.settings import Settings @@ -26,19 +28,22 @@ def test_cors_origins_list_property(): """Test: cors_origins_list convierte string a lista correctamente""" settings = Settings(CORS_ORIGINS="http://localhost:3000,http://localhost:4321") origins = settings.cors_origins_list - + assert isinstance(origins, list) assert len(origins) == 2 assert "http://localhost:3000" in origins assert "http://localhost:4321" in origins -@pytest.mark.parametrize("env,expected", [ - ("development", True), - ("test", True), - ("production", False), -]) +@pytest.mark.parametrize( + "env,expected", + [ + ("development", True), + ("test", True), + ("production", False), + ], +) def test_settings_debug_by_environment(env, expected): """Test: DEBUG se configura según el entorno""" settings = Settings(ENVIRONMENT=env, DEBUG=expected) - assert settings.DEBUG == expected \ No newline at end of file + assert expected == settings.DEBUG diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py index 50a9a19..e435579 100644 --- a/tests/unit/test_imports.py +++ b/tests/unit/test_imports.py @@ -2,30 +2,31 @@ """ Tests básicos de importación para verificar que no hay dependencias circulares. """ -import pytest def test_import_main(): """Test: Se puede importar main sin errores""" from app.main import app + assert app is not None def test_import_settings(): """Test: Se puede importar settings sin errores""" from app.config.settings import settings + assert settings is not None def test_import_all_routers(): """Test: Se pueden importar todos los routers""" from app.api.v1.routers import ( + cv_router, health_router, profile_router, skill_router, - cv_router ) - + assert health_router.router is not None assert profile_router.router is not None assert skill_router.router is not None @@ -34,12 +35,8 @@ def test_import_all_routers(): def test_import_all_schemas(): """Test: Se pueden importar todos los schemas principales""" - from app.api.schemas import ( - ProfileResponse, - SkillResponse, - CVCompleteResponse - ) - + from app.api.schemas import CVCompleteResponse, ProfileResponse, SkillResponse + assert ProfileResponse is not None assert SkillResponse is not None - assert CVCompleteResponse is not None \ No newline at end of file + assert CVCompleteResponse is not None diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 975a4be..6275e29 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -2,104 +2,103 @@ """ Tests unitarios para los schemas de Pydantic. """ + import pytest -from datetime import datetime from pydantic import ValidationError -from app.api.schemas.profile_schema import ProfileCreate, ProfileResponse -from app.api.schemas.skill_schema import SkillCreate, SkillLevel, SkillCategory +from app.api.schemas.profile_schema import ProfileCreate +from app.api.schemas.skill_schema import SkillCreate class TestProfileSchema: """Tests para Profile schemas""" - + def test_profile_create_valid(self): """Test: ProfileCreate se crea con datos válidos""" data = { "full_name": "John Doe", "headline": "Software Engineer", "about": "Experienced developer", - "location": "New York" + "location": "New York", } profile = ProfileCreate(**data) - + assert profile.full_name == "John Doe" assert profile.headline == "Software Engineer" - + def test_profile_create_missing_required_field(self): """Test: ProfileCreate falla sin campos requeridos""" data = { "full_name": "John Doe" # Falta 'headline' que es requerido } - + with pytest.raises(ValidationError) as exc_info: ProfileCreate(**data) - + assert "headline" in str(exc_info.value) - + def test_profile_create_empty_name(self): """Test: ProfileCreate falla con nombre vacío""" - data = { - "full_name": "", # Vacío, debería fallar - "headline": "Developer" - } - + data = {"full_name": "", "headline": "Developer"} # Vacío, debería fallar + with pytest.raises(ValidationError): ProfileCreate(**data) class TestSkillSchema: """Tests para Skill schemas""" - + def test_skill_create_valid(self): """Test: SkillCreate se crea con datos válidos""" data = { "name": "Python", "level": "expert", "category": "backend", - "order_index": 0 + "order_index": 0, } skill = SkillCreate(**data) - + assert skill.name == "Python" assert skill.level == "expert" assert skill.category == "backend" - + def test_skill_create_invalid_level(self): """Test: SkillCreate falla con nivel inválido""" data = { "name": "Python", "level": "master", # No es un nivel válido "category": "backend", - "order_index": 0 + "order_index": 0, } - + with pytest.raises(ValidationError) as exc_info: SkillCreate(**data) - + assert "level" in str(exc_info.value) - + def test_skill_create_invalid_category(self): """Test: SkillCreate falla con categoría inválida""" data = { "name": "Python", "level": "expert", "category": "programming", # No es categoría válida - "order_index": 0 + "order_index": 0, } - + with pytest.raises(ValidationError): SkillCreate(**data) - - @pytest.mark.parametrize("level", ["beginner", "intermediate", "advanced", "expert"]) + + @pytest.mark.parametrize( + "level", ["beginner", "intermediate", "advanced", "expert"] + ) def test_skill_all_valid_levels(self, level): """Test: SkillCreate acepta todos los niveles válidos""" data = { "name": "Python", "level": level, "category": "backend", - "order_index": 0 + "order_index": 0, } skill = SkillCreate(**data) - assert skill.level == level \ No newline at end of file + assert skill.level == level From 8e413f731d996725d8672300cbb3a78f67c990b8 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 5 Feb 2026 10:08:13 +0100 Subject: [PATCH 3/5] feat: (#60) Apply black formatter --- app/api/schemas/contact_info_schema.py | 9 ++------- app/api/schemas/cv_schema.py | 1 - app/api/schemas/profile_schema.py | 1 - app/api/schemas/projects_schema.py | 1 - app/api/v1/routers/contact_messages_router.py | 4 +++- app/api/v1/routers/skill_router.py | 6 +++++- app/domain/value_objects/contact_info.py | 4 +--- app/domain/value_objects/date_range.py | 4 +--- app/infrastructure/database/database.py | 4 +++- app/shared/interfaces/repository.py | 4 +--- app/shared/shared_exceptions/__init__.py | 1 - 11 files changed, 16 insertions(+), 23 deletions(-) diff --git a/app/api/schemas/contact_info_schema.py b/app/api/schemas/contact_info_schema.py index e18f7d5..8b5902e 100644 --- a/app/api/schemas/contact_info_schema.py +++ b/app/api/schemas/contact_info_schema.py @@ -1,4 +1,3 @@ - from pydantic import BaseModel, EmailStr, Field, HttpUrl from app.api.schemas.common_schema import TimestampMixin @@ -14,12 +13,8 @@ class ContactInformationBase(BaseModel): ..., description="Correo de contacto (no puede estar vacío)" ) phone: str | None = Field(None, description="Número de teléfono (opcional)") - location: str | None = Field( - None, description="Ubicación general (ciudad, país)" - ) - website: HttpUrl | None = Field( - None, description="Sitio web personal (opcional)" - ) + location: str | None = Field(None, description="Ubicación general (ciudad, país)") + website: HttpUrl | None = Field(None, description="Sitio web personal (opcional)") class ContactInformationCreate(ContactInformationBase): diff --git a/app/api/schemas/cv_schema.py b/app/api/schemas/cv_schema.py index 6f0bd18..228079c 100644 --- a/app/api/schemas/cv_schema.py +++ b/app/api/schemas/cv_schema.py @@ -1,4 +1,3 @@ - from pydantic import BaseModel from app.api.schemas.additional_training_schema import AdditionalTrainingResponse diff --git a/app/api/schemas/profile_schema.py b/app/api/schemas/profile_schema.py index 53d9665..ff7b399 100644 --- a/app/api/schemas/profile_schema.py +++ b/app/api/schemas/profile_schema.py @@ -1,4 +1,3 @@ - from pydantic import BaseModel, Field from app.api.schemas.common_schema import TimestampMixin diff --git a/app/api/schemas/projects_schema.py b/app/api/schemas/projects_schema.py index 00bda68..04e12bd 100644 --- a/app/api/schemas/projects_schema.py +++ b/app/api/schemas/projects_schema.py @@ -1,4 +1,3 @@ - from pydantic import BaseModel, Field, HttpUrl from app.api.schemas.common_schema import TimestampMixin diff --git a/app/api/v1/routers/contact_messages_router.py b/app/api/v1/routers/contact_messages_router.py index 7537602..1491ece 100644 --- a/app/api/v1/routers/contact_messages_router.py +++ b/app/api/v1/routers/contact_messages_router.py @@ -130,7 +130,9 @@ async def get_contact_message(message_id: str): summary="Enviar mensaje de contacto (PÚBLICO)", description="Crea un nuevo mensaje desde el formulario de contacto público", ) -async def create_contact_message(_message_data: ContactMessageCreate, _request: Request): +async def create_contact_message( + _message_data: ContactMessageCreate, _request: Request +): """ Crea un nuevo mensaje de contacto desde el formulario público del portfolio. diff --git a/app/api/v1/routers/skill_router.py b/app/api/v1/routers/skill_router.py index 8f092ea..eb1c7c2 100644 --- a/app/api/v1/routers/skill_router.py +++ b/app/api/v1/routers/skill_router.py @@ -473,7 +473,11 @@ async def get_skills_stats(): TODO: Implementar con GetSkillsStatsUseCase """ - stats: dict[str, Any] = {"total": len(MOCK_SKILLS), "by_level": {}, "by_category": {}} + stats: dict[str, Any] = { + "total": len(MOCK_SKILLS), + "by_level": {}, + "by_category": {}, + } # Contar por nivel for skill in MOCK_SKILLS: diff --git a/app/domain/value_objects/contact_info.py b/app/domain/value_objects/contact_info.py index 489e2ab..3bf4460 100644 --- a/app/domain/value_objects/contact_info.py +++ b/app/domain/value_objects/contact_info.py @@ -65,9 +65,7 @@ def create(email: str, phone: str | None = None) -> "ContactInfo": return ContactInfo(email=email_vo, phone=phone_vo) @staticmethod - def from_value_objects( - email: Email, phone: Phone | None = None - ) -> "ContactInfo": + def from_value_objects(email: Email, phone: Phone | None = None) -> "ContactInfo": """ Create ContactInfo from existing Email and Phone VOs. diff --git a/app/domain/value_objects/date_range.py b/app/domain/value_objects/date_range.py index 30af4ca..fedbf83 100644 --- a/app/domain/value_objects/date_range.py +++ b/app/domain/value_objects/date_range.py @@ -39,9 +39,7 @@ def __post_init__(self): self._validate() @staticmethod - def create( - start_date: datetime, end_date: datetime | None = None - ) -> "DateRange": + def create(start_date: datetime, end_date: datetime | None = None) -> "DateRange": """ Factory method to create a DateRange. diff --git a/app/infrastructure/database/database.py b/app/infrastructure/database/database.py index 489759e..7575d72 100644 --- a/app/infrastructure/database/database.py +++ b/app/infrastructure/database/database.py @@ -26,5 +26,7 @@ async def close_mongo_connection(): def get_database(): """Obtener instancia de la base de datos""" if db.client is None: - raise RuntimeError("MongoDB client not initialized. Call connect_to_mongo() first.") + raise RuntimeError( + "MongoDB client not initialized. Call connect_to_mongo() first." + ) return db.client[settings.MONGODB_DB_NAME] diff --git a/app/shared/interfaces/repository.py b/app/shared/interfaces/repository.py index bde30ee..a41d0ad 100644 --- a/app/shared/interfaces/repository.py +++ b/app/shared/interfaces/repository.py @@ -269,9 +269,7 @@ class IOrderedRepository(IRepository[T], Generic[T]): """ @abstractmethod - async def get_by_order_index( - self, profile_id: str, order_index: int - ) -> T | None: + async def get_by_order_index(self, profile_id: str, order_index: int) -> T | None: """ Get entity by its order index within a profile. diff --git a/app/shared/shared_exceptions/__init__.py b/app/shared/shared_exceptions/__init__.py index d836217..9422bfc 100644 --- a/app/shared/shared_exceptions/__init__.py +++ b/app/shared/shared_exceptions/__init__.py @@ -16,7 +16,6 @@ └── ForbiddenException """ - from typing import Any From 8e0a212e1cdea01c7da284d1dff731f883ed37b7 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 5 Feb 2026 10:10:08 +0100 Subject: [PATCH 4/5] feat: (#60) Add placeholder to test e2e --- tests/e2e/test_complete_cv_flow.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/e2e/test_complete_cv_flow.py b/tests/e2e/test_complete_cv_flow.py index e69de29..ae84467 100644 --- a/tests/e2e/test_complete_cv_flow.py +++ b/tests/e2e/test_complete_cv_flow.py @@ -0,0 +1,15 @@ +# tests/e2e/test_complete_cv_flow.py +""" +E2E tests for the complete CV flow. + +These tests will be implemented once the router endpoints +are connected to real use cases instead of mock data. +""" + +import pytest + + +@pytest.mark.e2e +def test_placeholder(): + """Placeholder to prevent pytest from failing on empty test directory.""" + pytest.skip("E2E tests pending: endpoints are still stubs with mock data") From eee39318253dcd1243ab3aae75aa0b2965df2a58 Mon Sep 17 00:00:00 2001 From: Alex Zapata Date: Thu, 5 Feb 2026 10:47:32 +0100 Subject: [PATCH 5/5] fix: (#60) add missing type alias imports with proper noqa comments - Import WorkExperience, Skill, Education, and other entities in TYPE_CHECKING block - Add # noqa: F401 comments since ruff doesn't detect usage in string literal annotations - Resolve type checking errors in type aliases section --- app/shared/interfaces/repository.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/shared/interfaces/repository.py b/app/shared/interfaces/repository.py index a41d0ad..03388d8 100644 --- a/app/shared/interfaces/repository.py +++ b/app/shared/interfaces/repository.py @@ -19,10 +19,18 @@ # Import entities only for type checking to avoid circular imports if TYPE_CHECKING: - from app.domain.entities import ( + from app.domain.entities import ( # noqa: F401 + AdditionalTraining, + Certification, + ContactInformation, ContactMessage, + Education, Profile, + Project, + Skill, SocialNetwork, + Tool, + WorkExperience, ) # Generic type representing any domain entity