diff --git a/backend/applications/errors/application_messages.py b/backend/applications/errors/application_messages.py index 55d3437b..e6209014 100644 --- a/backend/applications/errors/application_messages.py +++ b/backend/applications/errors/application_messages.py @@ -10,4 +10,4 @@ NOT_VALID_APPLICATION_STATE = "La postulación no está en estado pendiente." INVALID_APPLICATION_RELATIONSHIPS = "Relaciones de postulación no válidas." -ALREADY_APPLIED_SHIFTS = "Ya te postulaste a los turnos: {shifts}" \ No newline at end of file +ALREADY_APPLIED_SHIFTS = "Ya te postulaste a uno o todos los turnos asociados." \ No newline at end of file diff --git a/backend/applications/errors/attendance_messages.py b/backend/applications/errors/attendance_messages.py index 6a738f74..8549eaff 100644 --- a/backend/applications/errors/attendance_messages.py +++ b/backend/applications/errors/attendance_messages.py @@ -8,4 +8,7 @@ SHIFT_NOT_QR_ENABLED = "El turno no tiene el check-in por QR habilitado." EMPLOYEE_DOES_NOT_MATCH_TOKEN = "El empleado no coincide con el token." TOKEN_MISSING_REQUIRED_FIELDS = "Token missing required fields." -INVALID_TOKEN = "Token inválido o expirado." \ No newline at end of file +INVALID_TOKEN = "Token inválido o expirado." + +NOT_OFFER_ID = "El ID de la oferta es requerido." +NOT_OFFER_OWNER = "No sos el dueño de esta oferta." \ No newline at end of file diff --git a/backend/applications/models/__init__.py b/backend/applications/models/__init__.py index db1c14e0..d227ecbe 100644 --- a/backend/applications/models/__init__.py +++ b/backend/applications/models/__init__.py @@ -3,5 +3,7 @@ from .applications import Application __all__ = [ - 'Application' + 'Application', + 'Offer', + 'OfferState' ] \ No newline at end of file diff --git a/backend/applications/serializers/applications.py b/backend/applications/serializers/applications.py index 52066678..ce6bfab6 100644 --- a/backend/applications/serializers/applications.py +++ b/backend/applications/serializers/applications.py @@ -4,6 +4,9 @@ from eventos.formatters.date_time import CustomDateField, CustomTimeField from notifications.constants import NotificationTypes from notifications.services.send_notification import send_notification +from rating.utils import get_user_average_rating, get_user_rating_count +from user_auth.serializers.employee import ViewEmployeeEducationSerializer, ViewEmployeeWorkExperienceSerializer +from user_auth.serializers.language import EmployeeLanguageSerializer from user_auth.utils import get_city_locality, calculate_age from vacancies.constants import VacancyStates from applications.errors.application_messages import ALREADY_APPLIED_SHIFTS, EMPLOYEE_PROFILE_NOT_FOUND, NOT_PERMISSION_APPLICATION, NOT_PERMISSION_APPLICATION @@ -69,7 +72,7 @@ def validate(self, data): if already_applied_shifts: raise serializers.ValidationError( - ALREADY_APPLIED_SHIFTS.format(shifts=", ".join(map(str, already_applied_shifts))) + ALREADY_APPLIED_SHIFTS ) return data @@ -90,12 +93,23 @@ def save(self, **kwargs): Application(employee=employee, shift_id=shift_id, state=pending_state) for shift_id in shifts_ids ]) + + application = Application.objects.filter( + employee=employee, + shift_id__in=shifts_ids + ).first() send_notification( user=employer, - title="Nueva postulación", - message="POSTULADO nomas", - notification_type_name=NotificationTypes.APPLICATION.value + title="Postulacion", + message="Has recibido una nueva postulacion.", + notification_type_name=NotificationTypes.APPLICATION.value, + data={ + "application_id": application.id, + "vacancy_id": vacancy.id, + "employee_id": employee.id, + "shifts": shifts_ids + } ) return True @@ -103,6 +117,7 @@ def save(self, **kwargs): class ApplicationDetailSerializer(serializers.ModelSerializer): + employee_id = serializers.IntegerField(source="employee.id") current_shift = ShiftForApplicationSerializer(source='shift', read_only=True) shifts = serializers.SerializerMethodField() profile_image = serializers.SerializerMethodField() @@ -110,11 +125,16 @@ class ApplicationDetailSerializer(serializers.ModelSerializer): description = serializers.CharField(source='employee.description') age = serializers.SerializerMethodField() approximate_location = serializers.SerializerMethodField() + average_rating = serializers.SerializerMethodField() + rating_count = serializers.SerializerMethodField() + work_experiences = ViewEmployeeWorkExperienceSerializer(source='employee.work_experiences', many=True) + educations = ViewEmployeeEducationSerializer(source='employee.educations', many=True) + languages = EmployeeLanguageSerializer(source='employee.languages', many=True) class Meta: model = Application - fields = ["current_shift", "shifts", "profile_image", "name", "description", "age", "approximate_location"] - + fields = ["employee_id", "current_shift", "shifts", "profile_image", "name", "description", "age", "approximate_location", "average_rating", "rating_count", "work_experiences", "educations", "languages"] + def get_profile_image(self, obj): return obj.employee.user.profile_image.url if obj.employee.user.profile_image else None @@ -136,6 +156,12 @@ def get_shifts(self, obj): vacancy_shifts = getattr(obj.shift.vacancy, "_prefetched_objects_cache", {}).get("shifts", obj.shift.vacancy.shifts.all()) other_shifts = [s for s in vacancy_shifts if s.id != current_shift_id] return ShiftForApplicationSerializer(other_shifts, many=True).data + + def get_average_rating(self, obj): + return get_user_average_rating(obj.employee.user) + + def get_rating_count(self, obj): + return get_user_rating_count(obj.employee.user) def validate(self, attrs): user = self.context.get("user") @@ -151,6 +177,8 @@ class ApplicationByShiftSerializer(serializers.ModelSerializer): employee_id = serializers.IntegerField(source="employee.user_id") full_name = serializers.SerializerMethodField() profile_image = serializers.SerializerMethodField() + average_rating = serializers.SerializerMethodField() + rating_count = serializers.SerializerMethodField() class Meta: model = Application @@ -160,6 +188,8 @@ class Meta: "employee_id", "full_name", "profile_image", + "average_rating", + "rating_count", ] def get_full_name(self, obj): @@ -168,6 +198,12 @@ def get_full_name(self, obj): def get_profile_image(self, obj): return obj.employee.user.profile_image.url if obj.employee.user.profile_image else None + + def get_average_rating(self, obj): + return get_user_average_rating(obj.employee.user) + + def get_rating_count(self, obj): + return get_user_rating_count(obj.employee.user) class ShiftWithApplicationsSerializer(serializers.ModelSerializer): diff --git a/backend/applications/serializers/attendance.py b/backend/applications/serializers/attendance.py index b0b01e7a..c19c0ef2 100644 --- a/backend/applications/serializers/attendance.py +++ b/backend/applications/serializers/attendance.py @@ -104,8 +104,6 @@ def get_offer(self): """ offer_id = self.validated_data['offer_id'] return Offer.objects.get(pk=offer_id) - - class OfferByEventSerializer(serializers.ModelSerializer): employee_id = serializers.IntegerField(source="employee.user.id", read_only=True) diff --git a/backend/applications/serializers/jobs.py b/backend/applications/serializers/jobs.py index fa1c3ed4..8506117b 100644 --- a/backend/applications/serializers/jobs.py +++ b/backend/applications/serializers/jobs.py @@ -4,6 +4,9 @@ from applications.utils import get_job_type_display from eventos.formatters.date_time import CustomDateField, CustomTimeField from eventos.models.event import Event +from payments.constants import PaymentStates +from payments.models.payments import Payment +from rating.models.rating import Rating from vacancies.models.shifts import Shift @@ -58,4 +61,93 @@ def get_shift(self, obj): class EmployeeAcceptedEventsSerializer(serializers.ModelSerializer): class Meta: model = Event - fields = ["id", "name"] \ No newline at end of file + fields = ["id", "name"] + +class EmployeeJobsHistorySerializer(serializers.ModelSerializer): + event_id = serializers.IntegerField(source="selected_shift.vacancy.event.id") + event_name = serializers.CharField(source="selected_shift.vacancy.event.name") + event_image_url = serializers.SerializerMethodField() + + start_date = CustomDateField(source="selected_shift.start_date") + start_time = CustomTimeField(source="selected_shift.start_time") + payment_amount = serializers.FloatField(source="selected_shift.payment") + payment_mp_id = serializers.SerializerMethodField() + + company_name = serializers.CharField(source="employer.company_name") + + employer_image_url = serializers.SerializerMethodField() + job_type = serializers.SerializerMethodField() + payment_date = serializers.SerializerMethodField() + stars = serializers.SerializerMethodField() + comment = serializers.SerializerMethodField() + + class Meta: + model = Offer + fields = [ + "event_id", + "event_name", + "event_image_url", + "start_date", + "start_time", + "job_type", + "payment_amount", + "payment_mp_id", + "payment_date", + "company_name", + "employer_image_url", + "stars", + "comment", + ] + + def get_job_type(self, obj): + vacancy = obj.selected_shift.vacancy + return get_job_type_display(vacancy) + + def get_payment_date(self, obj): + payment = Payment.objects.filter( + offer=obj, + employee_id=obj.employee.user.id, + state__name=PaymentStates.APPROVED.value + ).order_by('-updated_at').first() + + if not payment: + return None + + return payment.updated_at.strftime("%d/%m/%Y") + + def get_payment_mp_id(self, obj): + payment = Payment.objects.filter( + offer=obj, + employee_id=obj.employee.user.id, + state__name=PaymentStates.APPROVED.value + ).order_by('-updated_at').first() + + if not payment: + return "El pago no está completo" + + return payment.mp_payment_id + + + + def get_stars(self, obj): + rating = Rating.objects.filter( + rater=obj.employee.user, + event=obj.selected_shift.vacancy.event + ).first() + return rating.rating if rating else None + + def get_comment(self, obj): + rating = Rating.objects.filter( + rater=obj.employee.user, + event=obj.selected_shift.vacancy.event + ).first() + return rating.comments if rating else None + + def get_event_image_url(self, obj): + if obj.selected_shift.vacancy.event.event_image: + return obj.selected_shift.vacancy.event.event_image.url + return None + + def get_employer_image_url(self, obj): + owner = obj.selected_shift.vacancy.event.owner + return owner.profile_image.url if owner.profile_image else None \ No newline at end of file diff --git a/backend/applications/serializers/offer.py b/backend/applications/serializers/offer.py index 23e7293f..4b570aab 100644 --- a/backend/applications/serializers/offer.py +++ b/backend/applications/serializers/offer.py @@ -6,12 +6,18 @@ from applications.models.applications_states import ApplicationState from applications.models.offers import Offer from applications.utils import get_job_type_display +from chats.services.stream_chat_service import sync_offer_chat from eventos.formatters.date_time import CustomDateField, CustomTimeField from eventos.serializers.event import EventSerializer +from notifications.constants import NotificationTypes +from notifications.services.send_notification import send_notification +from payments.models.payments import Payment +from rating.utils import get_user_average_rating, get_user_rating_count from user_auth.models.employee import EmployeeProfile from user_auth.models.employer import EmployerProfile from rest_framework import serializers from django.utils import timezone +from user_auth.models.user import CustomUser from vacancies.constants import VacancyStates from vacancies.models.shifts import Shift from vacancies.models.vacancy import Vacancy @@ -21,6 +27,9 @@ from vacancies.models.vacancy_state import VacancyState from django.db.models import Sum +import logging +logger = logging.getLogger(__name__) + class OfferCreateSerializer(serializers.ModelSerializer): application_id = serializers.IntegerField(required=False) @@ -44,6 +53,7 @@ class Meta: def validate(self, attrs): user = self.context['user'] application_id = attrs.get('application_id') + active_offer_states = [OfferStates.PENDING.value, OfferStates.ACCEPTED.value] try: employer = EmployerProfile.objects.get(user=user) @@ -76,8 +86,9 @@ def validate(self, attrs): shift = application.shift max_quantity = shift.quantity current_offers = Offer.objects.filter( - selected_shift=shift - ).exclude(state__name=OfferStates.REJECTED.value).count() + selected_shift=shift, + state__name__in=active_offer_states, + ).count() if current_offers >= max_quantity: raise serializers.ValidationError(MAX_OFFERS_REACHED.format(max_quantity=max_quantity)) @@ -120,13 +131,13 @@ def validate(self, attrs): max_quantity = shift.quantity current_offers = Offer.objects.filter( - selected_shift=shift - ).exclude(state__name=OfferStates.REJECTED.value).count() + selected_shift=shift, + state__name__in=active_offer_states, + ).count() if current_offers >= max_quantity: raise serializers.ValidationError(MAX_OFFERS_REACHED.format(max_quantity=max_quantity)) - # 🔹 Validar oferta repetida if Offer.objects.filter(employee=employee, employer=employer, selected_shift=shift).exists(): raise serializers.ValidationError(OFFER_ALREADY_EXISTS) @@ -165,6 +176,17 @@ def create(self, validated_data): application.state = offert_state application.save(update_fields=['state']) + send_notification( + user=employee.user, + title="¡Recibiste una oferta!", + message="Recibiste una nueva oferta de trabajo", + notification_type_name=NotificationTypes.OFFERT.value, + data={ + "employee_id": employee.id, + "offer_id": offers[0].id, + } + ) + return offers[0] if application else offers @@ -226,7 +248,7 @@ class Meta: ] def get_shift(self, obj): - # Si application_id es null, mostrar selected_shift + # Si application_id es null, mostrar selected_shift if obj.application_id is None: # Si existe selected_shift, devuélvelo serializado shift = getattr(obj, "selected_shift", None) @@ -274,6 +296,8 @@ def save(self, **kwargs): pending_state = ApplicationState.objects.get(name=ApplicationStates.PENDING.value) offer.application.state = pending_state offer.application.save(update_fields=['state']) + + offer.save(update_fields=['state', 'rejection_reason']) else: offer.state = OfferState.objects.get(name=OfferStates.ACCEPTED.value) @@ -283,6 +307,29 @@ def save(self, **kwargs): confirmed_state = ApplicationState.objects.get(name=ApplicationStates.CONFIRMED.value) offer.application.state = confirmed_state offer.application.save(update_fields=['state']) + + offer.save(update_fields=['state', 'rejection_reason']) + + # ---- NUEVO: Rechazar ofertas conflictivas del mismo usuario ---- + shift = offer.selected_shift + conflicting_offers = Offer.objects.filter( + employee=offer.employee, + state__name=OfferStates.PENDING.value, + selected_shift__start_time__lt=shift.end_time, + selected_shift__end_time__gt=shift.start_time + ).exclude(id=offer.id) + + rejected_state = OfferState.objects.get(name=OfferStates.REJECTED.value) + for o in conflicting_offers: + o.state = rejected_state + o.rejection_reason = "Conflicto de horario con otra oferta aceptada" + if o.application: + pending_state = ApplicationState.objects.get(name=ApplicationStates.PENDING.value) + o.application.state = pending_state + o.application.save(update_fields=['state']) + o.save(update_fields=['state', 'rejection_reason']) + + # ----------------------------------------------------------------- vacancy = offer.selected_shift.vacancy @@ -298,8 +345,27 @@ def save(self, **kwargs): filled_state = VacancyState.objects.get(name=VacancyStates.FILLED.value) vacancy.state = filled_state vacancy.save(update_fields=['state']) + + sync_offer_chat(offer) + + try: + employer_user = vacancy.event.owner + event_name = vacancy.event.name + + send_notification( + user=employer_user, + title="Oferta aceptada", + message = f"El empleado {user.first_name} {user.last_name} aceptó la oferta para trabajar en '{event_name}'.", + notification_type_name=NotificationTypes.OFFERT.value, + data={ + "event_id": vacancy.event.id, + "offer_id": offer.id + } + ) + except Exception as e: + logger.error(f"Error enviando notificación al empleador {getattr(offer, 'id', None)}: {e}") + - offer.save() return offer class RequirementSerializer(serializers.ModelSerializer): @@ -307,12 +373,32 @@ class Meta: model = Requirements fields = ["id", "description"] +class EventOwnerAvgSerializer(serializers.ModelSerializer): + full_name = serializers.SerializerMethodField() + average_rating = serializers.SerializerMethodField() + rating_count = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + fields = ["id", "full_name", "average_rating", "rating_count"] + + def get_full_name(self, obj): + return f"{obj.first_name} {obj.last_name}".strip() + + def get_average_rating(self, obj): + return get_user_average_rating(obj) + + def get_rating_count(self, obj): + return get_user_rating_count(obj) + + + class EventDetailSerializer(serializers.ModelSerializer): - owner_username = serializers.CharField(source="owner.username", read_only=True) + owner = EventOwnerAvgSerializer() class Meta: model = Event - fields = ["id", "name", "location", "latitude", "longitude","owner_username"] + fields = ["id", "name", "location", "latitude", "longitude","owner"] class VacancyDetailSerializer(serializers.ModelSerializer): event = EventDetailSerializer() @@ -356,16 +442,20 @@ class OfferDetailSerializer(serializers.ModelSerializer): event_image_url = serializers.SerializerMethodField() event_image_public_id = serializers.SerializerMethodField() shift = serializers.SerializerMethodField() + address = serializers.CharField(source="selected_shift.vacancy.event.location", read_only=True) + latitude = serializers.FloatField(source="selected_shift.vacancy.event.latitude", read_only=True) + longitude = serializers.FloatField(source="selected_shift.vacancy.event.longitude", read_only=True) + is_mp_associated = serializers.SerializerMethodField() class Meta: model = Offer fields = ["id", "expiration_date", "expiration_time", "additional_comments", "application", "shift", "event_image_url", - "event_image_public_id"] + "event_image_public_id", "address", "latitude", "longitude", "is_mp_associated" ] def get_shift(self, obj): - # Si application_id es null, mostrar selected_shift + # Si application_id es null, mostrar selected_shift if obj.application_id is None: # Si existe selected_shift, devuélvelo serializado shift = getattr(obj, "selected_shift", None) @@ -387,6 +477,12 @@ def get_event_image_public_id(self, obj): if event_image: return event_image.public_id return None + + def get_is_mp_associated(self, obj): + try: + return obj.employee.user.mercado_pago_account is not None + except AttributeError: + return False class OfferStateSerializer(serializers.ModelSerializer): class Meta: @@ -414,6 +510,8 @@ class OfferEventByStateSerializer(serializers.ModelSerializer): expiration_date = CustomDateField(required=False) expiration_time = CustomTimeField(required=False) + payment_state = serializers.SerializerMethodField() + payment_mp_id = serializers.SerializerMethodField() class Meta: model = Offer @@ -427,6 +525,8 @@ class Meta: "offer_state", "expiration_date", "expiration_time", + "payment_state", + "payment_mp_id" ] def get_profile_image(self, obj): @@ -434,6 +534,23 @@ def get_profile_image(self, obj): def get_job_type(self, obj): return get_job_type_display(obj.selected_shift.vacancy) + + def get_payment_state(self, obj): + # Intentamos obtener el Payment relacionado con esta oferta y empleado + payment = Payment.objects.filter(offer=obj, employee=obj.employee.user).first() + if payment and payment.state: + return payment.state.name + return "NOT_PAYED" + + def get_payment_mp_id(self, obj): + payment = Payment.objects.filter(offer=obj, employee=obj.employee.user).first() + if payment and payment.state: + return payment.mp_payment_id + return "El pago no está completo" + + + + class ShiftDetailOfferAcceptedSerializer(serializers.ModelSerializer): @@ -442,6 +559,9 @@ class ShiftDetailOfferAcceptedSerializer(serializers.ModelSerializer): requirements = RequirementSerializer(source="vacancy.requirements", many=True, read_only=True) event_name = serializers.CharField(source="vacancy.event.name", read_only=True) event_location = serializers.CharField(source="vacancy.event.location", read_only=True) + address = serializers.CharField(source="vacancy.event.location", read_only=True) + latitude = serializers.FloatField(source="vacancy.event.latitude", read_only=True) + longitude = serializers.FloatField(source="vacancy.event.longitude", read_only=True) class Meta: model = Shift @@ -456,6 +576,9 @@ class Meta: "requirements", "event_name", "event_location", + "address", + "latitude", + "longitude", ] def get_job_type(self, obj): diff --git a/backend/applications/urls.py b/backend/applications/urls.py index d07b2bb2..0d13972f 100644 --- a/backend/applications/urls.py +++ b/backend/applications/urls.py @@ -1,12 +1,11 @@ from django.urls import path from applications.views.applications import ApplicationCreateView, ApplicationDetailForOffer, ApplicationStatusRejectedUpdateView, ListApplicationsByShiftView, ApplicationDetailView -from applications.views.attendance import AttendanceConfirmationView, AttendanceDetailByEvent, GenerateQRTokenView -from applications.views.jobs import EmployeeJobsView, ListAcceptedEventsByEmployeeView +from applications.views.attendance import AttendanceConfirmationView, AttendanceDetailByEvent, CheckAttendanceView, GenerateQRTokenView +from applications.views.jobs import EmployeeJobsHistoryView, EmployeeJobsView, ListAcceptedEventsByEmployeeView from applications.views.jobs import EmployeeJobsView from applications.views.offer import EmployeeSearchDetailView, EmployeeSearchView, ListOfferEmployeeShiftsView, ListOfferEventByState, OfferAcceptedDetailView, OfferCreateView, OfferConsultView, DecideOfferView, OfferDetailView - urlpatterns = [ path('/detail/', ApplicationDetailForOffer.as_view(), name='application-detail'), path('apply/', ApplicationCreateView.as_view(), name='apply'), @@ -27,4 +26,6 @@ path('attendance//generate-qr/', GenerateQRTokenView.as_view(), name='generate-qr-token'), path('attendance/details//', AttendanceDetailByEvent.as_view(), name='attendance-detail-by-event'), path('events/accepted/by-employee/', ListAcceptedEventsByEmployeeView.as_view(), name='list-accepted-events-by-employee'), + path('attendance/check//', CheckAttendanceView.as_view(), name='attendance-check-offer'), + path('employee-jobs-history/', EmployeeJobsHistoryView.as_view(), name='employee-jobs-history'), ] diff --git a/backend/applications/views/attendance.py b/backend/applications/views/attendance.py index dcde2b3e..a620b717 100644 --- a/backend/applications/views/attendance.py +++ b/backend/applications/views/attendance.py @@ -3,6 +3,7 @@ from rest_framework import permissions, status from rest_framework.response import Response from applications.constants import OfferStates +from applications.errors.attendance_messages import NOT_OFFER_ID, NOT_OFFER_OWNER from applications.models.offer_state import OfferState from applications.models.offers import Offer from applications.serializers.attendance import AttendanceValidationSerializer, AttendanceResponseSerializer, GenerateQRTokenSerializer, OfferByEventSerializer @@ -11,6 +12,8 @@ from user_auth.permissions import IsInGroup from applications.models.attendance import Attendance from eventos.models.event import Event +from rest_framework import serializers +from rest_framework.views import APIView @@ -72,4 +75,29 @@ def get(self, request, *args, **kwargs): ).select_related("employee__user", "selected_shift__vacancy").order_by("selected_shift__start_date", "selected_shift__start_time") serializer = self.get_serializer(offers, many=True) - return Response(serializer.data, status=200) \ No newline at end of file + return Response(serializer.data, status=200) + +class CheckAttendanceView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def get(self, request, *args, **kwargs): + offer_id = kwargs.get("offer_id") # Lo obtenemos de la URL + + if not offer_id: + raise serializers.ValidationError(NOT_OFFER_ID) + + user = request.user + + # Verificar que la oferta existe y está aceptada + state_accepted = get_object_or_404(OfferState, name=OfferStates.ACCEPTED.value) + offer = get_object_or_404(Offer, pk=offer_id, state=state_accepted) + + # Verificar que el empleado sea dueño de la oferta + if offer.employee.user != user: + raise serializers.ValidationError(NOT_OFFER_OWNER) + + # Verificar si ya registró asistencia + attendance_exists = Attendance.objects.filter(employee=offer.employee, shift=offer.selected_shift).exists() + + return Response({"message": attendance_exists}, status=200) \ No newline at end of file diff --git a/backend/applications/views/jobs.py b/backend/applications/views/jobs.py index ef8ffd2a..ccba9f3d 100644 --- a/backend/applications/views/jobs.py +++ b/backend/applications/views/jobs.py @@ -7,7 +7,7 @@ from eventos.models.event import Event from user_auth.constants import EMPLOYEE_ROLE from user_auth.permissions import IsInGroup -from applications.serializers.jobs import EmployeeAcceptedEventsSerializer, EmployeeForSearchSerializer +from applications.serializers.jobs import EmployeeAcceptedEventsSerializer, EmployeeForSearchSerializer, EmployeeJobsHistorySerializer class EmployeeJobsView(ListAPIView): serializer_class = EmployeeForSearchSerializer @@ -45,7 +45,36 @@ def get_queryset(self): ] return Event.objects.filter( - vacancies__shifts__offers__employee=employee, - vacancies__shifts__offers__state=accepted_state, + vacancies__shifts__selected_offers__employee=employee, + vacancies__shifts__selected_offers__state=accepted_state, state__name__in=event_states_accepted, - ).distinct() \ No newline at end of file + ).distinct() + +class EmployeeJobsHistoryView(ListAPIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + serializer_class = EmployeeJobsHistorySerializer + + def get_queryset(self): + user = self.request.user + employee_profile = getattr(user, "employee_profile", None) + + if not employee_profile: + return Offer.objects.none() + + completed_state = OfferState.objects.filter( + name=OfferStates.COMPLETED.value + ).first() + + qs = Offer.objects.filter( + employee=employee_profile, + state=completed_state + ).select_related( + "selected_shift", + "selected_shift__vacancy", + "selected_shift__vacancy__event", + "selected_shift__vacancy__job_type", + "employer", + ).order_by("-updated_at") + + return qs diff --git a/backend/chats/__init__.py b/backend/chats/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/chats/admin.py b/backend/chats/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/chats/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/chats/apps.py b/backend/chats/apps.py new file mode 100644 index 00000000..10c2349f --- /dev/null +++ b/backend/chats/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'chats' diff --git a/backend/chats/migrations/__init__.py b/backend/chats/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/chats/services/stream_chat_service.py b/backend/chats/services/stream_chat_service.py new file mode 100644 index 00000000..006af576 --- /dev/null +++ b/backend/chats/services/stream_chat_service.py @@ -0,0 +1,142 @@ +from django.conf import settings +from stream_chat import StreamChat +import logging +from applications.models.offers import Offer +from applications.models.offer_state import OfferState +from applications.constants import OfferStates +import re + + +client = StreamChat(api_key=settings.STREAM_API_KEY, api_secret=settings.STREAM_API_SECRET) +logger = logging.getLogger(__name__) + + +def stream_user_id(user): + return str(user.id) + + +def upsert_user(user): + """Asegura que el usuario exista en Stream y retorna su UID.""" + uid = stream_user_id(user) + data = { + "id": uid, + "name": user.get_full_name() or user.email, + "role": "employer" if user.is_employer() else "employee", + } + if user.profile_image: + data["image"] = user.profile_image.url + client.upsert_user(data) + return uid + + +def create_user_token(user): + uid = stream_user_id(user) + return client.create_token(uid) + + +def get_or_create_channel(channel_type, channel_id, created_by_user_id, members=None, extra_data=None): + channel = client.channel(channel_type, channel_id) + try: + channel.create( + created_by_user_id, + data={ + "members": members or [], + "created_by_id": created_by_user_id, + **(extra_data or {}) + } + ) + except Exception as e: + if "Channel already exists" in str(e): + logger.info(f"Canal {channel_id} ya existe en {channel_type}.") + else: + logger.error(f"Error creando canal {channel_id}: {e}") + raise + return channel + + + +def add_members_to_channel(channel_type, channel_id, member_ids): + channel = client.channel(channel_type, channel_id) + channel.add_members(member_ids) + + +def add_single_member(channel_type, channel_id, member_id): + add_members_to_channel(channel_type, channel_id, [member_id]) + + +def send_system_message(channel_type, channel_id, text): + system_user = {"id": "system", "name": "Sistema"} + client.upsert_user(system_user) + channel = client.channel(channel_type, channel_id) + channel.send_message({"text": text}, system_user["id"]) + +def slugify_channel_id(prefix: str, name: str) -> str: + """ + Genera un channel_id válido para StreamChat. + Solo permite: a-z, 0-9, _ y - + Reemplaza espacios por _, quita otros caracteres. + """ + s = name.lower() # minúsculas + s = re.sub(r"\s+", "_", s) # espacios → _ + s = re.sub(r"[^a-z0-9_-]", "", s) # eliminar caracteres inválidos + return f"{prefix}-{s}" + + +def sync_offer_chat(offer): + """ + Sincroniza canales de Stream al aceptar una oferta: + - Crea canal de anuncios (empleador + empleados). + - Crea canal de empleados (entre empleados cuando >= 2). + - Añade miembros a los canales existentes. + """ + + vacancy = offer.selected_shift.vacancy + event = vacancy.event + + employer_uid = upsert_user(offer.employer.user) + employee_uid = upsert_user(offer.employee.user) + + # ----------------- + # 1. Canal de avisos (empleador + empleados) + # ----------------- + ann_channel_id = slugify_channel_id("ForoGrupal", event.name) + + if not event.stream_announcements_channel_id: + get_or_create_channel( + channel_type="announcements", + channel_id=ann_channel_id, + created_by_user_id=employer_uid, + members=[employer_uid, employee_uid], + extra_data={"event_id": str(event.pk), "name": f"Foro Grupal - {event.name}"} + ) + event.stream_announcements_channel_id = ann_channel_id + event.save(update_fields=["stream_announcements_channel_id"]) + else: + add_single_member("announcements", ann_channel_id, employee_uid) + + # ----------------- + # 2. Canal de empleados + # ----------------- + workers_channel_id = slugify_channel_id("Trabajadores", event.name) + + accepted_state = OfferState.objects.get(name=OfferStates.ACCEPTED.value) + accepted_offers = Offer.objects.filter( + selected_shift__vacancy__event=event, + state=accepted_state + ).select_related("employee__user") + + accepted_employee_ids = [upsert_user(o.employee.user) for o in accepted_offers] + print(accepted_employee_ids) + + if len(accepted_employee_ids) == 2 and not event.stream_workers_channel_id: + get_or_create_channel( + channel_type="messaging", + channel_id=workers_channel_id, + created_by_user_id=accepted_employee_ids[0], + members=accepted_employee_ids, + extra_data={"event_id": str(event.pk), "name": f"Trabajadores - {event.name}"} + ) + event.stream_workers_channel_id = workers_channel_id + event.save(update_fields=["stream_workers_channel_id"]) + elif len(accepted_employee_ids) > 2: + add_single_member("messaging", workers_channel_id, employee_uid) diff --git a/backend/chats/urls.py b/backend/chats/urls.py new file mode 100644 index 00000000..879bc118 --- /dev/null +++ b/backend/chats/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from chats.views.chat import StreamTokenView + + +urlpatterns = [ + path('stream/token/', StreamTokenView.as_view(), name='stream_token'), +] \ No newline at end of file diff --git a/backend/chats/views/chat.py b/backend/chats/views/chat.py new file mode 100644 index 00000000..0dd82cab --- /dev/null +++ b/backend/chats/views/chat.py @@ -0,0 +1,37 @@ +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.conf import settings +from chats.services.stream_chat_service import stream_user_id, upsert_user, create_user_token +from user_auth.constants import EMPLOYEE_ROLE, EMPLOYER_ROLE +from user_auth.permissions import IsInGroup + +class StreamTokenView(APIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE, EMPLOYEE_ROLE] + + def get(self, request): + uid = upsert_user(request.user) + token = create_user_token(request.user) + + if request.user.is_employer(): + company_name = getattr(request.user.employer_profile, "company_name", None) + display_name = company_name + else: + display_name = f"{request.user.first_name} {request.user.last_name}".strip() + + user_data = { + "id": uid, + "name": display_name, + "role": "employer" if request.user.is_employer() else "employee", + } + + if getattr(request.user, "profile_image", None): + user_data["image"] = request.user.profile_image.url + + + return Response({ + "api_key": settings.STREAM_API_KEY, + "user": user_data, + "token": token, + }) \ No newline at end of file diff --git a/backend/config/__init__.py b/backend/config/__init__.py index e69de29b..14012e69 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -0,0 +1 @@ +# project/__init__.py diff --git a/backend/config/settings.py b/backend/config/settings.py index 208f170a..cf8aa3e4 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -64,8 +64,10 @@ 'vacancies', 'applications', 'notifications', + 'chats', 'allauth', 'allauth.account', + 'allauth.socialaccount', 'allauth.socialaccount.providers.google', @@ -73,6 +75,8 @@ 'cloudinary', 'cloudinary_storage', 'media_utils', + 'rating', + "payments", ] @@ -114,7 +118,6 @@ SOCIALACCOUNT_PROVIDERS = { 'google': { - 'APP': { 'client_id': os.getenv('SOCIAL_AUTH_GOOGLE_OAUTH2_CLIENT_ID'), 'secret': os.getenv('SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET'), @@ -128,6 +131,9 @@ #LOGOUT_REDIRECT_URL = '' ROOT_URLCONF = 'config.urls' +SOCIALACCOUNT_ADAPTER = "user_auth.adapters.CustomSocialAccountAdapter" + + # Email configuration EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') @@ -261,4 +267,47 @@ QR_JWT_SECRET = os.getenv("QR_JWT_SECRET") QR_JWT_ALGORITHM = os.getenv("QR_JWT_ALGORITHM", default="HS256") -QR_JWT_EXP_MINUTES = int(os.getenv("QR_JWT_EXP_MINUTES", default=2)) \ No newline at end of file +QR_JWT_EXP_MINUTES = int(os.getenv("QR_JWT_EXP_MINUTES", default=2)) + +# Mercado PAGO + +MP_CLIENT_ID = os.getenv("MP_CLIENT_ID") +MP_CLIENT_SECRET = os.getenv("MP_CLIENT_SECRET") +MP_AUTH_REDIRECT_URI = os.getenv("MP_AUTH_REDIRECT_URI") +MP_API_URL = os.getenv("MP_API_URL", default="https://api.mercadopago.com") +MP_TOKEN_URL = os.getenv("MP_TOKEN_URL", default="https://api.mercadopago.com/oauth/token") +JWT_MP_SECRET = os.getenv("JWT_MP_SECRET") +MP_ACCESS_TOKEN = os.getenv('MP_ACCESS_TOKEN') +MP_SUCCESS_URL = os.getenv('MP_SUCCESS_URL') +MP_FAILURE_URL = os.getenv('MP_FAILURE_URL') +MP_PENDING_URL = os.getenv('MP_PENDING_URL') +MP_WEBHOOK_SECRET = os.getenv('MP_WEBHOOK_SECRET') +MP_COLLECTION_ID = os.getenv('MP_COLLECTION_ID') + +# Stream CHAT + +STREAM_API_KEY = os.getenv('STREAM_API_KEY') +STREAM_API_SECRET = os.getenv('STREAM_API_SECRET') + +# LOGGING + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'ignore_warnings': { + '()': 'django.utils.log.CallbackFilter', + 'callback': lambda record: not record.getMessage().startswith('UserWarning'), + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'filters': ['ignore_warnings'], + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} diff --git a/backend/config/urls.py b/backend/config/urls.py index bab7f36f..bd03941b 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -24,6 +24,9 @@ path('api/vacancies/', include('vacancies.urls')), path('api/applications/', include('applications.urls')), path('api/notifications/', include('notifications.urls')), + path('api/chats/', include('chats.urls')), path('accounts/', include('allauth.urls')), path('media/', include('media_utils.urls')), + path('api/rating/', include('rating.urls')), + path('api/payments/', include('payments.urls')), ] diff --git a/backend/eventos/constants.py b/backend/eventos/constants.py index 6838f3db..538c16bd 100644 --- a/backend/eventos/constants.py +++ b/backend/eventos/constants.py @@ -19,6 +19,19 @@ class CategoryEnum(str, Enum): DESFILE = "Desfile" EVENTO_DEPORTIVO = "Evento deportivo" OTROS = "Otros" +class CategoryEnum2(Enum): + CONFERENCIA = "Conferencia" + EXPO = "Expo" + SEMINARIO = "Seminario" + BIRTHDAY = "Cumpleaños" + FIESTASECRETA = "Fiesta secreta" + BABYSHOWER = "Baby shower" + ANIVERSARIO = "Aniversario" + OBRATEATRO = "Obra de teatro" + FERIA = "Feria" class Unaccent(Func): - function = 'UNACCENT' \ No newline at end of file + function = 'UNACCENT' + + +OFFER_VALID_STATES = ['ACCEPTED', 'NOT_SHOWN', 'COMPLETED'] \ No newline at end of file diff --git a/backend/eventos/management/__init__.py b/backend/eventos/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/eventos/management/commands/__init__.py b/backend/eventos/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/eventos/management/commands/check_event_end.py b/backend/eventos/management/commands/check_event_end.py new file mode 100644 index 00000000..6d3c353d --- /dev/null +++ b/backend/eventos/management/commands/check_event_end.py @@ -0,0 +1,127 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.db import transaction +from django.db.models import Q +import logging + +from eventos.models import Event, EventState +from eventos.constants import EventStates +from vacancies.models import VacancyState +from vacancies.constants import VacancyStates +from applications.models import Offer +from applications.models.attendance import Attendance +from applications.models.offer_state import OfferState +from applications.constants import OfferStates + +from stream_chat import StreamChat +from django.conf import settings + +logger = logging.getLogger(__name__) + + +def get_state_or_raise(StateModel, enum_value): + try: + return StateModel.objects.get(name=enum_value) + except StateModel.DoesNotExist: + raise RuntimeError(f"{StateModel.__name__} no tiene el estado '{enum_value}'") + + +def combine_date_time_to_dt(date_field, time_field): + from django.utils import timezone as dj_tz + import datetime + naive = datetime.datetime.combine(date_field, time_field) + tz = dj_tz.get_current_timezone() + return dj_tz.make_aware(naive, tz) if dj_tz.settings.USE_TZ else naive + + +class Command(BaseCommand): + help = "Verifica si hay eventos finalizados y actualiza estados" + + def handle(self, *args, **options): + now = timezone.now() + logger.info("check_event_end running at %s", now) + + client = StreamChat( + api_key=settings.STREAM_API_KEY, + api_secret=settings.STREAM_API_SECRET + ) + + try: + published = get_state_or_raise(EventState, EventStates.PUBLISHED.value) + in_progress = get_state_or_raise(EventState, EventStates.IN_PROGRESS.value) + finalized = get_state_or_raise(EventState, EventStates.FINALIZED.value) + vac_active = get_state_or_raise(VacancyState, VacancyStates.ACTIVE.value) + vac_expired = get_state_or_raise(VacancyState, VacancyStates.EXPIRED.value) + offer_pending = get_state_or_raise(OfferState, OfferStates.PENDING.value) + offer_accepted = get_state_or_raise(OfferState, OfferStates.ACCEPTED.value) + offer_completed = get_state_or_raise(OfferState, OfferStates.COMPLETED.value) + offer_not_shown = get_state_or_raise(OfferState, OfferStates.NOT_SHOWN.value) + offer_expired = get_state_or_raise(OfferState, OfferStates.EXPIRED.value) + + except RuntimeError as e: + logger.error("Estados faltantes: %s", e) + return + + events = Event.objects.filter(state__in=[published, in_progress]).filter( + Q(end_date__lt=now.date()) | + Q(end_date=now.date(), end_time__lt=now.time()) + ) + + total_finalized = 0 + for ev in events: + end_dt = combine_date_time_to_dt(ev.end_date, ev.end_time) + if end_dt <= now: + with transaction.atomic(): + ev.state = finalized + + try: + if ev.stream_announcements_channel_id: + client.channel("announcements", ev.stream_announcements_channel_id).delete() + logger.info(f"Canal de anuncios eliminado: {ev.stream_announcements_channel_id}") + + if ev.stream_workers_channel_id: + client.channel("messaging", ev.stream_workers_channel_id).delete() + logger.info(f"Canal de trabajadores eliminado: {ev.stream_workers_channel_id}") + + except Exception as e: + logger.warning(f"Error al eliminar canales de StreamChat del evento {ev.pk}: {e}") + + ev.stream_announcements_channel_id = None + ev.stream_workers_channel_id = None + + ev.save(update_fields=[ + "state", + "updated_at", + "stream_announcements_channel_id", + "stream_workers_channel_id" + ]) + total_finalized += 1 + + ev.vacancies.filter(state=vac_active).update(state=vac_expired) + + offers_qs = Offer.objects.filter(selected_shift__vacancy__event=ev).distinct() + + pending_qs = offers_qs.filter(state=offer_pending) + n_pending = pending_qs.update(state=offer_expired, updated_at=now) + + accepted_qs = offers_qs.filter(state=offer_accepted) + n_completed = 0 + n_not_shown = 0 + for offer in accepted_qs: + attended = Attendance.objects.filter( + shift_id=offer.selected_shift_id, + employee_id=offer.employee_id + ).exists() + offer.state = offer_completed if attended else offer_not_shown + offer.updated_at = now + offer.save(update_fields=["state", "updated_at"]) + n_completed += 1 if attended else 0 + n_not_shown += 0 if attended else 1 + + logger.info( + "Evento %s finalizado: pendientes expiradas=%d, completadas=%d, no mostradas=%d", + ev.pk, n_pending, n_completed, n_not_shown + ) + + logger.info("check_event_end finished. total_finalized=%d", total_finalized) + diff --git a/backend/eventos/management/commands/check_event_start.py b/backend/eventos/management/commands/check_event_start.py new file mode 100644 index 00000000..69d2cd59 --- /dev/null +++ b/backend/eventos/management/commands/check_event_start.py @@ -0,0 +1,66 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.db import transaction +from django.db.models import Q + +import logging +logger = logging.getLogger(__name__) + +from eventos.models import Event, EventState +from eventos.constants import EventStates +from applications.models import Offer +from applications.models.offer_state import OfferState +from applications.constants import OfferStates + + +def get_state_or_raise(StateModel, enum_value): + try: + return StateModel.objects.get(name=enum_value) + except StateModel.DoesNotExist: + raise RuntimeError(f"{StateModel.__name__} no tiene el estado '{enum_value}'") + + +def combine_date_time_to_dt(date_field, time_field): + """Devuelve datetime con timezone""" + from django.utils import timezone as dj_tz + import datetime + naive = datetime.datetime.combine(date_field, time_field) + tz = dj_tz.get_current_timezone() + return dj_tz.make_aware(naive, tz) if dj_tz.settings.USE_TZ else naive + + +class Command(BaseCommand): + help = "Verifica si hay eventos que deben iniciar y actualiza su estado a 'En curso'" + + def handle(self, *args, **options): + now = timezone.now() + logger.info("check_event_start running at %s", now) + + try: + published = get_state_or_raise(EventState, EventStates.PUBLISHED.value) + in_progress = get_state_or_raise(EventState, EventStates.IN_PROGRESS.value) + offer_pending = get_state_or_raise(OfferState, OfferStates.PENDING.value) + offer_expired = get_state_or_raise(OfferState, OfferStates.EXPIRED.value) + except RuntimeError as e: + logger.error("Estados faltantes: %s", e) + return + + events = Event.objects.filter(state=published).filter( + Q(start_date__lt=now.date()) | + Q(start_date=now.date(), start_time__lte=now.time()) + ) + + total_started = 0 + for ev in events: + start_dt = combine_date_time_to_dt(ev.start_date, ev.start_time) + if start_dt <= now: + with transaction.atomic(): + ev.state = in_progress + ev.save(update_fields=["state", "updated_at"]) + total_started += 1 + + offers_qs = Offer.objects.filter(selected_shift__vacancy__event=ev, state=offer_pending) + n = offers_qs.update(state=offer_expired, updated_at=now) + logger.info("Evento %s iniciado: %d ofertas expiradas", ev.pk, n) + + logger.info("check_event_start finished. total_started=%d", total_started) \ No newline at end of file diff --git a/backend/eventos/management/commands/expire_offers.py b/backend/eventos/management/commands/expire_offers.py new file mode 100644 index 00000000..f8119192 --- /dev/null +++ b/backend/eventos/management/commands/expire_offers.py @@ -0,0 +1,33 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.db.models import Q +import logging + +from applications.models import Offer +from applications.models.offer_state import OfferState +from applications.constants import OfferStates + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Expira ofertas cuyo tiempo de vigencia terminó" + + def handle(self, *args, **options): + now = timezone.now() + logger.info("expire_offers running at %s", now) + + try: + offer_pending = OfferState.objects.get(name=OfferStates.PENDING.value) + offer_expired = OfferState.objects.get(name=OfferStates.EXPIRED.value) + except OfferState.DoesNotExist as e: + logger.error("Estados faltantes: %s", e) + return + + offers = Offer.objects.filter(state=offer_pending).filter( + Q(expiration_date__lt=now.date()) | + Q(expiration_date=now.date(), expiration_time__lt=now.time()) + ) + + n = offers.update(state=offer_expired, updated_at=now) + logger.info("expire_offers: expired %d offers", n) diff --git a/backend/eventos/management/commands/process_tasks.py b/backend/eventos/management/commands/process_tasks.py new file mode 100644 index 00000000..db20370e --- /dev/null +++ b/backend/eventos/management/commands/process_tasks.py @@ -0,0 +1,31 @@ +from django.core.management.base import BaseCommand +from django.core import management +import logging + +logger = logging.getLogger(__name__) + +class Command(BaseCommand): + help = "Ejecuta los procesos automáticos de eventos y ofertas (inicio, finalización y expiración)" + + def handle(self, *args, **options): + logger.info("=== Iniciando ejecución automática de eventos ===") + + try: + management.call_command('check_event_start') + logger.info("✔ check_event_start ejecutado correctamente") + except Exception as e: + logger.exception("❌ Error ejecutando check_event_start: %s", e) + + try: + management.call_command('check_event_end') + logger.info("✔ check_event_end ejecutado correctamente") + except Exception as e: + logger.exception("❌ Error ejecutando check_event_end: %s", e) + + try: + management.call_command('expire_offers') + logger.info("✔ expire_offers ejecutado correctamente") + except Exception as e: + logger.exception("❌ Error ejecutando expire_offers: %s", e) + + logger.info("=== Proceso automático completado ===") diff --git a/backend/eventos/migrations/0006_event_stream_announcements_channel_id_and_more.py b/backend/eventos/migrations/0006_event_stream_announcements_channel_id_and_more.py new file mode 100644 index 00000000..b3335e73 --- /dev/null +++ b/backend/eventos/migrations/0006_event_stream_announcements_channel_id_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.23 on 2025-09-29 23:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eventos', '0005_event_event_image'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='stream_announcements_channel_id', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='event', + name='stream_workers_channel_id', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/backend/eventos/migrations/0007_add_new_categories.py b/backend/eventos/migrations/0007_add_new_categories.py new file mode 100644 index 00000000..f587606a --- /dev/null +++ b/backend/eventos/migrations/0007_add_new_categories.py @@ -0,0 +1,49 @@ +from django.db import migrations + + +def create_new_categories(apps, schema_editor): + Category = apps.get_model('eventos', 'Category') + + try: + from eventos.constants import CategoryEnum2 + except Exception: + # Si no existe CategoryEnum2 o hay error al importarlo, no hacemos nada + return + + for member in CategoryEnum2: + name = str(member.value).strip() + if name: + Category.objects.get_or_create(name=name) + + +def delete_new_categories(apps, schema_editor): + """ + Reverse: eliminar las categorías definidas en CategoryEnum2. + Atención: esto borra por nombre exacto. Si una categoría con ese nombre existía + antes de ejecutar la migración, también será eliminada. + """ + Category = apps.get_model('eventos', 'Category') + + try: + from eventos.constants import CategoryEnum2 + except Exception: + # Si el enum no existe en el momento del rollback, intentar nada/no eliminar + return + + names = [str(member.value).strip() for member in CategoryEnum2 if str(member.value).strip()] + if not names: + return + + # Borrar por nombre exacto + Category.objects.filter(name__in=names).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('eventos', '0006_event_stream_announcements_channel_id_and_more'), + ] + + operations = [ + migrations.RunPython(create_new_categories, reverse_code=delete_new_categories), + ] \ No newline at end of file diff --git a/backend/eventos/models/event.py b/backend/eventos/models/event.py index a8b2adf2..3031d956 100644 --- a/backend/eventos/models/event.py +++ b/backend/eventos/models/event.py @@ -29,6 +29,8 @@ class Event(models.Model): blank=True, related_name='event_image' ) + stream_announcements_channel_id = models.CharField(max_length=100, null=True, blank=True) + stream_workers_channel_id = models.CharField(max_length=100, null=True, blank=True) def __str__(self): return self.name diff --git a/backend/eventos/serializers/event.py b/backend/eventos/serializers/event.py index 4bdc203f..44758409 100644 --- a/backend/eventos/serializers/event.py +++ b/backend/eventos/serializers/event.py @@ -1,19 +1,28 @@ from rest_framework import serializers +from applications.models.attendance import Attendance from applications.utils import get_job_type_display from eventos.errors.events_messages import BOTH_PROFILE_IMAGE_FIELDS_REQUIRED, EVENT_START_DATE_AFTER_END_DATE, EVENT_START_TIME_NOT_BEFORE_END_TIME, EVENT_START_DATE_IN_PAST, INVALID_STATE_ID from eventos.models.category_events import Category from eventos.models.event import Event from eventos.models.state_events import EventState -from eventos.constants import EventStates +from eventos.constants import OFFER_VALID_STATES, EventStates from eventos.serializers.category_events import ListCategorySerializer from eventos.serializers.state_events import EventStateSerializer from eventos.formatters.date_time import CustomDateField, CustomTimeField from media_utils.models import Image, ImageType from media_utils.serializers import ImageSerializer +from payments.constants import PaymentStates +from payments.models.payment_state import PaymentState +from payments.models.payments import Payment +from rating.models.penalty import Penalty +from rating.models.users_connections import UserConnection from user_auth.models.user import CustomUser from datetime import date from vacancies.models.vacancy import Vacancy - +from rating.models import Behavior +from applications.models import Offer +from user_auth.models.employee import EmployeeProfile +from rating.utils import get_user_average_rating, get_user_rating_count, has_already_rated class CreateEventSerializer(serializers.ModelSerializer): category_id = serializers.PrimaryKeyRelatedField( @@ -84,7 +93,7 @@ def create(self, validated_data): } ) - public_state = EventState.objects.get(name=EventStates.PUBLISHED.value) + public_state = EventState.objects.get(name=EventStates.DRAFT.value) validated_data['owner'] = user validated_data['state'] = public_state @@ -125,14 +134,22 @@ class Meta: class EventOwnerSerializer(serializers.ModelSerializer): profile_image = ImageSerializer(allow_null=True) - full_name = serializers.SerializerMethodField() + company_name = serializers.SerializerMethodField() + average_rating = serializers.SerializerMethodField() + rating_count = serializers.SerializerMethodField() class Meta: model = CustomUser - fields = ['id', 'first_name', 'last_name', 'full_name', 'email', 'profile_image'] + fields = ['id', 'company_name', 'email', 'profile_image', 'average_rating', 'rating_count'] + + def get_company_name(self, obj): + return f"{obj.employer_profile.company_name}" + + def get_average_rating(self, obj): + return get_user_average_rating(obj) - def get_full_name(self, obj): - return f"{obj.first_name} {obj.last_name}".strip() + def get_rating_count(self, obj): + return get_user_rating_count(obj) class EventSerializer(serializers.ModelSerializer): @@ -141,10 +158,13 @@ class EventSerializer(serializers.ModelSerializer): state = EventStateSerializer() event_image_public_id = serializers.SerializerMethodField() event_image_url = serializers.SerializerMethodField() + address = serializers.CharField(source='location') + latitude = serializers.FloatField() + longitude = serializers.FloatField() class Meta: model = Event - fields = ['id', 'name', 'description', 'owner', 'category', 'state', 'event_image_public_id', 'event_image_url'] + fields = ['id', 'name', 'description', 'owner', 'category', 'state', 'event_image_public_id', 'event_image_url', 'address', 'latitude', 'longitude'] def get_event_image_public_id(self, obj): event_image = getattr(obj, 'event_image', None) @@ -160,11 +180,29 @@ class Meta: fields = ['id', 'name'] class ListEventsByEmployerSerializer(serializers.ModelSerializer): - state = EventStateSerializer() + all_payed = serializers.SerializerMethodField() + class Meta: model = Event - fields = ['id', 'name', 'state'] + fields = ['id', 'name', 'state', 'all_payed'] + + def get_all_payed(self, event): + payments = [ + payment + for vacancy in event.vacancies.all() + for shift in vacancy.shifts.all() + for offer in shift.selected_offers.all() + for payment in offer.payment_set.all() + ] + + if not payments: + return False + + return all( + payment.state.name == PaymentStates.APPROVED.value + for payment in payments + ) class ListEventDetailSerializer(serializers.ModelSerializer): @@ -266,3 +304,199 @@ class Meta: model = Event fields = ['event_id', 'event_name', 'vacancies'] + + +class ListEventsEmployeeSerializer(serializers.ModelSerializer): + employee_id = serializers.IntegerField(source="employee.user.id") + name = serializers.SerializerMethodField() + job_type = serializers.SerializerMethodField() + image = serializers.SerializerMethodField() + rating = serializers.SerializerMethodField() + already_rated = serializers.SerializerMethodField() + is_linked = serializers.SerializerMethodField() + is_penalized = serializers.SerializerMethodField() + has_shown = serializers.SerializerMethodField() + + class Meta: + model = Offer + fields = [ + "employee_id", + "name", + "job_type", + "image", + "rating", + "already_rated", + "is_linked", + "is_penalized", + "has_shown" + ] + + def get_name(self, obj): + user = obj.employee.user + return f"{user.first_name} {user.last_name}" + + def get_job_type(self, obj): + return get_job_type_display(obj.selected_shift.vacancy) + + def get_image(self, obj): + user = obj.employee.user + return user.profile_image.url if user.profile_image else None + + def get_rating(self, obj): + user = obj.employee.user + behavior = Behavior.objects.filter(user=user).order_by('-created_at').first() + return behavior.average_rating if behavior else None + + def get_already_rated(self, obj): + request = self.context.get('request') + # owner is the employer user for the event + rated_user = obj.employee.user + # current user (the rater) may be anonymous in some contexts + rater = getattr(request, 'user', None) if request is not None else None + + # event instance + event = obj.selected_shift.vacancy.event + + return has_already_rated( + event=event, rater=rater, rated_user=rated_user + ) + + def get_is_linked(self, obj): + """ + Devuelve True si el empleado está vinculado con el empleador actual + """ + request = self.context.get('request') + employer_user = getattr(request, 'user', None) + employee_user = obj.employee.user + + if not employer_user: + return False + + return UserConnection.objects.filter( + employee=employee_user, + employer=employer_user + ).exists() + + def get_is_penalized(self, obj): + """ + Devuelve True si el empleado ya fue penalizado en este evento. + """ + request = self.context.get('request') + punisher = getattr(request, 'user', None) + event = obj.selected_shift.vacancy.event + employee_user = obj.employee.user + + # Buscar si existe una penalización creada por este empleador para este empleado en el evento + return Penalty.objects.filter( + punisher=punisher, + event=event, + behavior__user=employee_user + ).exists() + + def get_has_shown(self, obj): + """ + Devuelve True si el empleado asistió al shift de la oferta. + """ + return Attendance.objects.filter( + employee=obj.employee, + shift=obj.selected_shift + ).exists() + +class EmployeeReportSerializer(serializers.Serializer): + employee_id = serializers.IntegerField(source='employee.user.id') + employee_name = serializers.CharField(source='employee.user.get_full_name') + attendance = serializers.SerializerMethodField() + payment_status = serializers.SerializerMethodField() + rating = serializers.SerializerMethodField() + profile_image_url = serializers.SerializerMethodField() + job_type = serializers.SerializerMethodField() + + def get_attendance(self, obj): + # Verifica si el empleado asistió al shift de la oferta + return Attendance.objects.filter( + employee=obj.employee, + shift=obj.selected_shift + ).exists() + + def get_payment_status(self, obj): + # Obtiene el estado del pago asociado a la oferta + payment = Payment.objects.filter(offer=obj).first() + if not payment: + return "NO_PAYMENT" + return payment.state.name + + def get_rating(self, obj): + event = self.context.get("event") + behavior = getattr(obj.employee.user, "behaviors", None) + if not behavior: + return None + + behavior_instance = behavior.first() + if not behavior_instance: + return None + + rating_obj = behavior_instance.ratings.filter(event=event).first() + return rating_obj.rating if rating_obj else None + + def get_profile_image_url(self, obj): + user = obj.employee.user + return user.profile_image.url if user.profile_image else None + + def get_job_type(self, obj): + return get_job_type_display(obj.selected_shift.vacancy) + + +class EventReportSerializer(serializers.ModelSerializer): + total_offers_accepted = serializers.SerializerMethodField() + total_attendances_confirmed = serializers.SerializerMethodField() + total_payments_approved = serializers.SerializerMethodField() + employees = serializers.SerializerMethodField() + + class Meta: + model = Event + fields = [ + 'id', 'name', + 'total_offers_accepted', + 'total_attendances_confirmed', + 'total_payments_approved', + 'employees' + ] + + def get_total_offers_accepted(self, obj): + return Offer.objects.filter( + selected_shift__vacancy__event=obj, + state__name__in=OFFER_VALID_STATES + ).count() + + def get_total_attendances_confirmed(self, obj): + return Attendance.objects.filter( + shift__vacancy__event=obj + ).count() + + def get_total_payments_approved(self, obj): + approved_state = PaymentState.objects.get(name=PaymentStates.APPROVED.value) + return Payment.objects.filter( + offer__selected_shift__vacancy__event=obj, + state=approved_state + ).count() + + def get_employees(self, obj): + offers = Offer.objects.filter( + selected_shift__vacancy__event=obj, + state__name__in=OFFER_VALID_STATES + ) + return EmployeeReportSerializer( + offers, many=True, context={"event": obj} + ).data + +class ListHistoryEventsViewSerializer(serializers.ModelSerializer): + + event_image = serializers.SerializerMethodField() + class Meta: + model = Event + fields = ['id', 'name', 'description', 'event_image', 'start_date', 'end_date'] + + def get_event_image(self, obj): + if obj.event_image: + return obj.event_image.url + return None \ No newline at end of file diff --git a/backend/eventos/urls.py b/backend/eventos/urls.py index 378fe4ef..459df26a 100644 --- a/backend/eventos/urls.py +++ b/backend/eventos/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from eventos.views.event import CreateEventView, DeleteEventView, ListEventsByEmployerView, ListActiveEventsView, ListEventDetailView, ListEventVacanciesView, ListEventsWithVacanciesView, UpdateEventStateView, UpdateEventView +from eventos.views.event import CreateEventView, DeleteEventView, ListEventsByEmployerView, ListActiveEventsView, ListEventDetailView, ListEventVacanciesView, ListEventsWithVacanciesView, ListHistoryEventsView, ReportEventView, UpdateEventStateView, UpdateEventView, ListEventsEmployeeView from eventos.views.category_events import ListCategoryView @@ -16,5 +16,7 @@ path('vacancies/', ListEventVacanciesView.as_view(), name='event-vacancies'), path('by-employer/', ListEventsByEmployerView.as_view(), name='list-active-events-by-employer'), path('with-vacancies-availables/', ListEventsWithVacanciesView.as_view(), name='list-events-with-vacancies'), - + path('/employee/', ListEventsEmployeeView.as_view(), name='list-employee-event'), + path('/report/', ReportEventView.as_view(), name='report-event'), + path('history-events/', ListHistoryEventsView.as_view(), name='history-events') ] diff --git a/backend/eventos/views/event.py b/backend/eventos/views/event.py index d1eb94af..f8a19316 100644 --- a/backend/eventos/views/event.py +++ b/backend/eventos/views/event.py @@ -1,15 +1,26 @@ +from django.shortcuts import get_object_or_404 +from payments.models.payments import Payment from rest_framework.generics import CreateAPIView, ListAPIView, RetrieveAPIView, UpdateAPIView +from applications.constants import OfferStates +from applications.models.offer_state import OfferState from eventos.constants import EventStates from eventos.errors.events_messages import ESTADO_DELETED_NO_CONFIGURADO, EVENT_NOT_FOUND, NO_EDITAR_EVENTO_PUBLICADO, NO_PERMISSION_EVENT, STATE_UPDATED_SUCCESS from eventos.models.event import Event from eventos.models.state_events import EventState -from eventos.serializers.event import CreateEventSerializer, CreateEventResponseSerializer, ListActiveEventsSerializer, ListEventDetailSerializer, ListEventVacanciesSerializer, ListEventsByEmployerSerializer, ListEventsWithVacanciesSerializer, UpdateEventStateSerializer +from eventos.serializers.event import CreateEventSerializer, CreateEventResponseSerializer, EventReportSerializer, ListActiveEventsSerializer, ListEventDetailSerializer, ListEventVacanciesSerializer, ListEventsByEmployerSerializer, ListEventsEmployeeSerializer, ListEventsWithVacanciesSerializer, ListHistoryEventsViewSerializer, UpdateEventStateSerializer,ListEventsEmployeeSerializer +from rating.models.rating import Rating from user_auth.constants import EMPLOYEE_ROLE, EMPLOYER_ROLE from user_auth.permissions import IsInGroup from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import status +from applications.models import Offer +from datetime import date +from django.db.models import Case, DateField, When, Value, IntegerField, F, ExpressionWrapper, DurationField +from django.db.models.functions import ExtractDay +from django.db.models.functions import Abs + from vacancies.constants import VacancyStates @@ -82,7 +93,7 @@ def get_serializer_context(self): def update(self, request, *args, **kwargs): instance = self.get_object() - published_state = EventStates.objects.get(name=EventStates.PUBLISHED.value) + published_state = EventState.objects.get(name=EventStates.PUBLISHED.value) if instance.state == published_state: return Response( NO_EDITAR_EVENTO_PUBLICADO, @@ -166,32 +177,59 @@ def delete(self, request, pk): return Response({"detail": "Evento eliminado correctamente."}, status=status.HTTP_200_OK) - - class ListEventsByEmployerView(ListAPIView): - """ - Lista los eventos activos del empleador autenticado. - """ permission_classes = [IsAuthenticated, IsInGroup] required_groups = [EMPLOYER_ROLE] serializer_class = ListEventsByEmployerSerializer + active_states = [ + EventStates.DRAFT.value, EventStates.PUBLISHED.value, EventStates.IN_PROGRESS.value, EventStates.FINALIZED.value, ] def get_queryset(self): - user = self.request.user + payments_prefetch = Prefetch( + "vacancies__shifts__selected_offers__payment_set", + queryset=Payment.objects.select_related("state"), + ) + return ( Event.objects .filter( - owner=user, + owner=self.request.user, state__name__in=self.active_states ) - .only("id", "name", "state") + .select_related("state") + .prefetch_related(payments_prefetch) + .annotate( + state_priority=Case( + When(state__name=EventStates.IN_PROGRESS.value, then=Value(1)), + When(state__name=EventStates.PUBLISHED.value, then=Value(2)), + When(state__name=EventStates.DRAFT.value, then=Value(3)), + When(state__name=EventStates.FINALIZED.value, then=Value(4)), + default=Value(99), + output_field=IntegerField(), + ), + sort_date_asc=Case( + When(state__name=EventStates.FINALIZED.value, then=Value(None)), + default=F("start_date"), + output_field=DateField(), + ), + sort_date_desc=Case( + When(state__name=EventStates.FINALIZED.value, then=F("end_date")), + default=Value(None), + output_field=DateField(), + ), + ) + .order_by( + "state_priority", + F("sort_date_asc").asc(nulls_last=True), + F("sort_date_desc").desc(nulls_last=True), + ) ) - + class ListEventsWithVacanciesView(ListAPIView): permission_classes = [IsAuthenticated, IsInGroup] required_groups = [EMPLOYER_ROLE] @@ -215,4 +253,71 @@ def get_queryset(self): .prefetch_related( Prefetch("vacancies", queryset=active_vacancies_qs) ) - ) \ No newline at end of file + ) +class ListEventsEmployeeView(ListAPIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + serializer_class = ListEventsEmployeeSerializer + + def get_queryset(self): + request = self.request + rater = request.user # el empleador actual + event_id = self.kwargs.get('eventId') + + states_to_include = OfferState.objects.filter( + name__in=[ + OfferStates.COMPLETED.value, + OfferStates.NOT_SHOWN.value + ] + ) + + # Ofertas del evento con esos estados + qs = Offer.objects.filter( + selected_shift__vacancy__event_id=event_id, + state__in=states_to_include + ).select_related( + "employee", + "employee__user", + "selected_shift", + "selected_shift__vacancy", + "selected_shift__vacancy__event", + ) + + # Empleados (usuarios) que ya fueron calificados por este empleador en este evento + rated_employee_ids = Rating.objects.filter( + rater=rater, + event_id=event_id + ).values_list("behavior__user_id", flat=True) + + # Excluir empleados ya calificados + qs = qs.exclude(employee__user_id__in=rated_employee_ids) + + return qs + + +class ReportEventView(APIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + + def get(self, request, event_id, *args, **kwargs): + event = get_object_or_404(Event, id=event_id) + serializer = EventReportSerializer(event) + return Response(serializer.data) + +class ListHistoryEventsView(ListAPIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + serializer_class = ListHistoryEventsViewSerializer + historical_states = [ + EventStates.FINALIZED.value, + ] + + def get_queryset(self): + user = self.request.user + return ( + Event.objects + .filter( + owner=user, + state__name__in=self.historical_states + ) + ) diff --git a/backend/media_utils/errors/media_errors_messages.py b/backend/media_utils/errors/media_errors_messages.py new file mode 100644 index 00000000..beeff287 --- /dev/null +++ b/backend/media_utils/errors/media_errors_messages.py @@ -0,0 +1,3 @@ +CLOUDINARY_MISSING_FOLDER_PARAM = "El query param folder es requerido" +CLOUDINARY_INVALID_FOLDER = "Nombre de carpeta invalido" +CLOUDINARY_CREDENTIALS_NOT_CONFIGURED = "Credenciales de Cloudinary no configuradas." diff --git a/backend/media_utils/views.py b/backend/media_utils/views.py index aab3a586..3f4adc24 100644 --- a/backend/media_utils/views.py +++ b/backend/media_utils/views.py @@ -4,31 +4,40 @@ from django.conf import settings import time import cloudinary.utils +from rest_framework import serializers +from media_utils.errors.media_errors_messages import CLOUDINARY_CREDENTIALS_NOT_CONFIGURED, CLOUDINARY_INVALID_FOLDER, CLOUDINARY_MISSING_FOLDER_PARAM class CloudinarySignedUploadView(APIView): permission_classes = [IsAuthenticated] + ALLOWED_FOLDERS = { + 'events-images', + 'user-profiles-images', + 'certificates-docs', + 'work-experiences-docs', + } + def get(self, request): timestamp = int(time.time()) - folder = request.query_params.get('folder', 'user_profiles') + folder = request.query_params.get('folder') - params_to_sign = { - 'timestamp': timestamp, - 'folder': folder, - } + if not folder: + raise serializers.ValidationError(CLOUDINARY_MISSING_FOLDER_PARAM) + + if folder not in self.ALLOWED_FOLDERS: + raise serializers.ValidationError(CLOUDINARY_INVALID_FOLDER) + + params_to_sign = {'timestamp': timestamp, 'folder': folder} api_secret = settings.CLOUDINARY_STORAGE.get('API_SECRET') api_key = settings.CLOUDINARY_STORAGE.get('API_KEY') cloud_name = settings.CLOUDINARY_STORAGE.get('CLOUD_NAME') if not all([api_secret, api_key, cloud_name]): - return Response({"error": "Cloudinary credentials not configured"}, status=500) + raise serializers.ValidationError(CLOUDINARY_CREDENTIALS_NOT_CONFIGURED) - signature = cloudinary.utils.api_sign_request( - params_to_sign, - api_secret - ) + signature = cloudinary.utils.api_sign_request(params_to_sign, api_secret) return Response({ 'timestamp': timestamp, diff --git a/backend/notifications/migrations/0004_notification_action_notificationtype_image.py b/backend/notifications/migrations/0004_notification_action_notificationtype_image.py new file mode 100644 index 00000000..8e800500 --- /dev/null +++ b/backend/notifications/migrations/0004_notification_action_notificationtype_image.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.23 on 2025-10-25 17:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('media_utils', '0002_initial'), + ('notifications', '0003_create_device_model'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='action', + field=models.CharField(blank=True, null=True), + ), + migrations.AddField( + model_name='notificationtype', + name='image', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notification_type_image', to='media_utils.image'), + ), + ] diff --git a/backend/notifications/migrations/0005_remove_notification_action_notification_data.py b/backend/notifications/migrations/0005_remove_notification_action_notification_data.py new file mode 100644 index 00000000..0a4a3af4 --- /dev/null +++ b/backend/notifications/migrations/0005_remove_notification_action_notification_data.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.23 on 2025-10-25 18:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_notification_action_notificationtype_image'), + ] + + operations = [ + migrations.RemoveField( + model_name='notification', + name='action', + ), + migrations.AddField( + model_name='notification', + name='data', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/backend/notifications/models/notification.py b/backend/notifications/models/notification.py index db2b3728..8f7a7284 100644 --- a/backend/notifications/models/notification.py +++ b/backend/notifications/models/notification.py @@ -13,4 +13,5 @@ class Notification(models.Model): on_delete=models.PROTECT, related_name='notifications' ) + data = models.JSONField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) \ No newline at end of file diff --git a/backend/notifications/models/notification_type.py b/backend/notifications/models/notification_type.py index c062207c..18fd0725 100644 --- a/backend/notifications/models/notification_type.py +++ b/backend/notifications/models/notification_type.py @@ -1,4 +1,12 @@ from django.db import models +from media_utils.models import Image class NotificationType(models.Model): name = models.CharField(max_length=50, unique=True) + image = models.OneToOneField( + Image, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='notification_type_image' + ) diff --git a/backend/notifications/services/send_notification.py b/backend/notifications/services/send_notification.py index 14d5add6..44048fa1 100644 --- a/backend/notifications/services/send_notification.py +++ b/backend/notifications/services/send_notification.py @@ -3,10 +3,9 @@ from config import settings from notifications.models.notification import Notification from notifications.models.notification_type import NotificationType +import logging -# Configuramos el logger (una sola vez en tu módulo) logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) # Nivel mínimo de log # Opcional: agregar handler si no hay uno configurado (para desarrollo local) if not logger.hasHandlers(): @@ -17,7 +16,7 @@ handler.setFormatter(formatter) logger.addHandler(handler) -def send_notification(user, title, message, notification_type_name): +def send_notification(user, title, message, notification_type_name, data=None): """ Crea una notificación en la base de datos y envía push a todos los dispositivos del usuario. @@ -35,7 +34,8 @@ def send_notification(user, title, message, notification_type_name): user=user, title=title, message=message, - notification_type=notif_type + notification_type=notif_type, + data=data or {} ) # Enviar push a todos los dispositivos del usuario @@ -47,10 +47,11 @@ def send_notification(user, title, message, notification_type_name): "title": title, "body": message, "channelId": "default", - "data": {"type": notification_type_name} + "data": data } try: resp = requests.post(settings.EXPO_PUSH_API_URL, json=payload, timeout=5) logger.info("Push enviado a %s | %s", token, resp.status_code) except requests.RequestException as e: + logger.info("Error enviando push a %s: %s", token, e) logger.error("Error enviando push a %s: %s", token, e) \ No newline at end of file diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py index 8c901cf3..2927c5c8 100644 --- a/backend/notifications/urls.py +++ b/backend/notifications/urls.py @@ -1,6 +1,6 @@ from django.urls import path from notifications.views.device import DeviceRegisterView -from notifications.views.notification import ListNotificationView, SetNotificationAllReadView, SetNotificationReadView +from notifications.views.notification import ListNotificationView, NewNotificationsAdviceView, SetNotificationAllReadView, SetNotificationReadView urlpatterns = [ @@ -8,5 +8,5 @@ path('/read/', SetNotificationReadView.as_view(), name='notification-detail'), path('mark-all-read/', SetNotificationAllReadView.as_view(), name='notification-mark-all-read'), path('devices/register/', DeviceRegisterView.as_view(), name='device-register'), - + path('are-new-notifications/', NewNotificationsAdviceView.as_view(), name='are-new-notifications'), ] \ No newline at end of file diff --git a/backend/notifications/views/device.py b/backend/notifications/views/device.py index caa7660d..22e85eb8 100644 --- a/backend/notifications/views/device.py +++ b/backend/notifications/views/device.py @@ -14,6 +14,8 @@ def post(self, request): expo_token = serializer.validated_data["expo_push_token"] + Device.objects.filter(expo_push_token=expo_token).exclude(user=request.user).delete() + device, created = Device.objects.update_or_create( user=request.user, expo_push_token=expo_token, diff --git a/backend/notifications/views/notification.py b/backend/notifications/views/notification.py index 761c8464..902ff3e5 100644 --- a/backend/notifications/views/notification.py +++ b/backend/notifications/views/notification.py @@ -57,4 +57,13 @@ def post(self, request): return Response( {"message": f"Se marcaron {updated_count} notificaciones como leídas."}, status=status.HTTP_200_OK - ) \ No newline at end of file + ) + +class NewNotificationsAdviceView(APIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE, EMPLOYER_ROLE] + + def get(self, request): + user = request.user + has_new = user.notifications.filter(read=False).exists() + return Response({"message": has_new}) \ No newline at end of file diff --git a/backend/payments/__init__.py b/backend/payments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/payments/admin.py b/backend/payments/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/payments/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/payments/apps.py b/backend/payments/apps.py new file mode 100644 index 00000000..61898af0 --- /dev/null +++ b/backend/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "payments" diff --git a/backend/payments/constants.py b/backend/payments/constants.py new file mode 100644 index 00000000..e280cf14 --- /dev/null +++ b/backend/payments/constants.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class PaymentStates(str, Enum): + APPROVED = "APPROVED" + PENDING = "PENDING" + FAILURE = "FAILURE" + + +MP_FEE_PERCENT = 0.0653 +APP_FEE_PERCENT = 0.0347 \ No newline at end of file diff --git a/backend/payments/errors/mercado_pago.py b/backend/payments/errors/mercado_pago.py new file mode 100644 index 00000000..2afc6b63 --- /dev/null +++ b/backend/payments/errors/mercado_pago.py @@ -0,0 +1,12 @@ +NO_VALID_PAYMENT="No se pudo validar el pago" +PAYMENT_NOT_FOUND="Pago no encontrado" +PAYMENT_STATE_ERROR="Error con el estado del pago" +MISSING_MP_CODE="Falta el código de Mercado Pago" +NO_VALID_PAYMENT="No se pudo validar el pago" +INVALID_SIGNATURE="Firma inválida" + +OFFER_NOT_EXIST = "La oferta no existe" +OFFER_NO_SHIFT = "La oferta no tiene un turno asociado" +SHIFT_NO_VACANCY = "El turno no tiene una vacante asociada" +NO_PERMISSION_FOR_PAYMENT = "No tiene permisos para generar un pago sobre esta oferta" +EMPLOYEE_NO_MP_ACCOUNT = "El empleado no tiene una cuenta de Mercado Pago asociada" \ No newline at end of file diff --git a/backend/payments/migrations/0001_initial.py b/backend/payments/migrations/0001_initial.py new file mode 100644 index 00000000..2ffa3864 --- /dev/null +++ b/backend/payments/migrations/0001_initial.py @@ -0,0 +1,106 @@ +# Generated by Django 4.2.23 on 2025-10-03 09:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("applications", "0008_add_job_offer_states"), + ] + + operations = [ + migrations.CreateModel( + name="PaymentState", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=50, unique=True)), + ], + ), + migrations.CreateModel( + name="Payment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("commission", models.DecimalField(decimal_places=2, max_digits=10)), + ("concept", models.CharField(max_length=255)), + ("mp_payment_id", models.CharField(max_length=100)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "offer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="applications.offer", + ), + ), + ( + "state", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="payments", + to="payments.paymentstate", + ), + ), + ], + ), + migrations.CreateModel( + name="MercadoPagoAccount", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("mp_user_id", models.CharField(max_length=100, unique=True)), + ("access_token", models.TextField()), + ("refresh_token", models.TextField()), + ("public_key", models.CharField(blank=True, max_length=200, null=True)), + ("live_mode", models.BooleanField(default=False)), + ("expires_in", models.IntegerField(blank=True, null=True)), + ("scope", models.CharField(blank=True, max_length=200, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="mercado_pago_account", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/backend/payments/migrations/0002_auto_20251003_0930.py b/backend/payments/migrations/0002_auto_20251003_0930.py new file mode 100644 index 00000000..9b0e1545 --- /dev/null +++ b/backend/payments/migrations/0002_auto_20251003_0930.py @@ -0,0 +1,18 @@ + +from django.db import migrations +from payments.constants import PaymentStates + +def create_payment_states(apps, schema_editor): + PaymentState = apps.get_model("payments", "PaymentState") + for state in PaymentStates: + PaymentState.objects.get_or_create(name=state.value) + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0001_initial"), + ] + + operations = [ + migrations.RunPython(create_payment_states), + ] \ No newline at end of file diff --git a/backend/payments/migrations/0003_alter_payment_mp_payment_id.py b/backend/payments/migrations/0003_alter_payment_mp_payment_id.py new file mode 100644 index 00000000..3571686e --- /dev/null +++ b/backend/payments/migrations/0003_alter_payment_mp_payment_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.23 on 2025-10-25 16:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0002_auto_20251003_0930"), + ] + + operations = [ + migrations.AlterField( + model_name="payment", + name="mp_payment_id", + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/backend/payments/migrations/0004_alter_mercadopagoaccount_mp_user_id.py b/backend/payments/migrations/0004_alter_mercadopagoaccount_mp_user_id.py new file mode 100644 index 00000000..9509e1b1 --- /dev/null +++ b/backend/payments/migrations/0004_alter_mercadopagoaccount_mp_user_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.23 on 2025-10-25 16:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0003_alter_payment_mp_payment_id"), + ] + + operations = [ + migrations.AlterField( + model_name="mercadopagoaccount", + name="mp_user_id", + field=models.IntegerField(unique=True), + ), + ] diff --git a/backend/payments/migrations/0005_alter_mercadopagoaccount_mp_user_id.py b/backend/payments/migrations/0005_alter_mercadopagoaccount_mp_user_id.py new file mode 100644 index 00000000..4fe3ffbb --- /dev/null +++ b/backend/payments/migrations/0005_alter_mercadopagoaccount_mp_user_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.23 on 2025-10-26 18:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0004_alter_mercadopagoaccount_mp_user_id"), + ] + + operations = [ + migrations.AlterField( + model_name="mercadopagoaccount", + name="mp_user_id", + field=models.BigIntegerField(unique=True), + ), + ] diff --git a/backend/payments/migrations/__init__.py b/backend/payments/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/payments/models/__init__.py b/backend/payments/models/__init__.py new file mode 100644 index 00000000..4710b871 --- /dev/null +++ b/backend/payments/models/__init__.py @@ -0,0 +1,9 @@ +from .payment_state import PaymentState +from .payments import Payment +from .mercado_pago import MercadoPagoAccount + +__all__ = [ + 'PaymentState', + 'Payment', + 'MercadoPagoAccount', +] \ No newline at end of file diff --git a/backend/payments/models/mercado_pago.py b/backend/payments/models/mercado_pago.py new file mode 100644 index 00000000..a1a01481 --- /dev/null +++ b/backend/payments/models/mercado_pago.py @@ -0,0 +1,25 @@ +from django.db import models + +from user_auth.models.user import CustomUser + + + +class MercadoPagoAccount(models.Model): + user = models.OneToOneField( + CustomUser, + on_delete=models.CASCADE, + related_name="mercado_pago_account" + ) + mp_user_id = models.BigIntegerField(unique=True) + access_token = models.TextField() # Token para operar + refresh_token = models.TextField() # Para renovar access_token + public_key = models.CharField(max_length=200, blank=True, null=True) + live_mode = models.BooleanField(default=False) + expires_in = models.IntegerField(null=True, blank=True) # segundos de expiración + scope = models.CharField(max_length=200, blank=True, null=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"MercadoPagoAccount({self.user.email})" \ No newline at end of file diff --git a/backend/payments/models/payment_state.py b/backend/payments/models/payment_state.py new file mode 100644 index 00000000..cd81cf8d --- /dev/null +++ b/backend/payments/models/payment_state.py @@ -0,0 +1,9 @@ +from django.db import models + + + +class PaymentState(models.Model): + name = models.CharField(max_length=50, unique=True) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/backend/payments/models/payments.py b/backend/payments/models/payments.py new file mode 100644 index 00000000..8557a390 --- /dev/null +++ b/backend/payments/models/payments.py @@ -0,0 +1,16 @@ +from django.db import models + +from applications.models.offers import Offer +from payments.models.payment_state import PaymentState +from user_auth.models.user import CustomUser + +class Payment(models.Model): + offer = models.ForeignKey(Offer, on_delete=models.CASCADE) + employee = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + amount = models.DecimalField(max_digits=10, decimal_places=2) + commission = models.DecimalField(max_digits=10, decimal_places=2) + concept = models.CharField(max_length=255) + mp_payment_id = models.CharField(max_length=100, null=True, blank=True) + state = models.ForeignKey(PaymentState, on_delete=models.CASCADE, related_name='payments') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) \ No newline at end of file diff --git a/backend/payments/serializers/payments.py b/backend/payments/serializers/payments.py new file mode 100644 index 00000000..c152d9ae --- /dev/null +++ b/backend/payments/serializers/payments.py @@ -0,0 +1,68 @@ +# payments/serializers.py +from rest_framework import serializers + +from applications.models.offers import Offer +from applications.utils import get_job_type_display +from payments.errors.mercado_pago import EMPLOYEE_NO_MP_ACCOUNT, NO_PERMISSION_FOR_PAYMENT, OFFER_NO_SHIFT, OFFER_NOT_EXIST, SHIFT_NO_VACANCY +from payments.models.mercado_pago import MercadoPagoAccount + + +class GeneratePaymentLinkSerializer(serializers.Serializer): + offer_id = serializers.IntegerField(required=True) + + def validate(self, data): + request = self.context.get("request") + offer_id = data.get("offer_id") + + # Validar existencia de la oferta con relaciones necesarias + try: + offer = Offer.objects.select_related( + "selected_shift__vacancy__event__owner", + "employee__user" + ).get(id=offer_id) + except Offer.DoesNotExist: + raise serializers.ValidationError(OFFER_NOT_EXIST) + + if not offer.selected_shift: + raise serializers.ValidationError(OFFER_NO_SHIFT) + + vacancy = offer.selected_shift.vacancy + if not vacancy: + raise serializers.ValidationError(SHIFT_NO_VACANCY) + + # Validar que el owner de la vacante sea el mismo empleador que hace la request + if request and vacancy.event.owner != request.user: + raise serializers.ValidationError(NO_PERMISSION_FOR_PAYMENT) + + # Validar cuenta MP del empleado + try: + employee_account = offer.employee.user.mercado_pago_account + except MercadoPagoAccount.DoesNotExist: + raise serializers.ValidationError(EMPLOYEE_NO_MP_ACCOUNT) + + # Enriquecer datos validados + data["offer"] = offer + data["amount"] = offer.selected_shift.payment + data["employee_account"] = employee_account + data["commission"] = float(offer.selected_shift.payment) * 0.1 + + # Concepto de la transferencia + vacancy_job_type = get_job_type_display(vacancy) + employee_name = f"{offer.employee.user.first_name} {offer.employee.user.last_name}".strip() + concept = f"{vacancy_job_type} - Pago a {employee_name}" + data["concept"] = concept + + return data + +class PaymentCallbackSerializer(serializers.Serializer): + payment_id = serializers.CharField(required=False, allow_blank=True) + preference_id = serializers.CharField(required=False, allow_blank=True) + + def validate(self, data): + # Intentar extraer payment_id desde data.id si no viene directamente + if not data.get("payment_id") and "data" in self.initial_data: + data["payment_id"] = self.initial_data["data"].get("id") + + if not data.get("payment_id") and not data.get("preference_id"): + raise serializers.ValidationError("Se requiere payment_id o preference_id") + return data \ No newline at end of file diff --git a/backend/payments/services/mercado_pago_service.py b/backend/payments/services/mercado_pago_service.py new file mode 100644 index 00000000..bed3ad49 --- /dev/null +++ b/backend/payments/services/mercado_pago_service.py @@ -0,0 +1,142 @@ +from datetime import datetime, timedelta +from decimal import ROUND_HALF_UP, Decimal +from django.conf import settings +import jwt +import requests +from typing import Optional +from payments.constants import APP_FEE_PERCENT, MP_FEE_PERCENT +from user_auth.models.user import CustomUser + +class MercadoPagoService: + + @staticmethod + def exchange_code_for_tokens(code: str) -> dict: + data = { + "client_secret": settings.MP_CLIENT_SECRET, + "client_id": settings.MP_CLIENT_ID, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": settings.MP_AUTH_REDIRECT_URI, + } + + resp = requests.post(settings.MP_TOKEN_URL, data=data) + + if resp.status_code != 200: + raise Exception(f"Mercado Pago token request failed: {resp.json()}") + + return resp.json() + + @staticmethod + def generate_oauth_state(user_id: int, expires_minutes: int = 5) -> str: + """ + Genera un JWT temporal que se usará como `state` en el flujo OAuth de Mercado Pago. + """ + payload = { + "sub": user_id, + "exp": datetime.utcnow() + timedelta(minutes=expires_minutes), + "type": "mp_oauth" + } + token = jwt.encode(payload, settings.JWT_MP_SECRET, algorithm="HS256") + return token + + @staticmethod + def decode_oauth_state(state_token: str) -> CustomUser: + """ + Decodifica el JWT temporal recibido como `state` y devuelve el usuario correspondiente. + Lanza excepción si el token es inválido o expirado. + """ + try: + payload = jwt.decode(state_token, settings.JWT_MP_SECRET, algorithms=["HS256"]) + user_id = payload.get("sub") + return CustomUser.objects.get(id=user_id) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, CustomUser.DoesNotExist) as e: + raise Exception("Invalid or expired state token") from e + + @staticmethod + def get_access_token() -> str: + """ + Obtiene dinámicamente el token de acceso de Mercado Pago + usando client_id y client_secret (OAuth Client Credentials). + """ + resp = requests.post( + settings.MP_TOKEN_URL, + data={ + "grant_type": "client_credentials", + "client_id": settings.MP_CLIENT_ID, + "client_secret": settings.MP_CLIENT_SECRET + } + ) + if resp.status_code != 200: + raise Exception(f"Error obteniendo access token MP: {resp.json()}") + + return resp.json()["access_token"] + + @staticmethod + def create_payment_link( + employee_account, + amount: float, + concept: Optional[str] = None, + external_reference: Optional[str] = None + ) -> str: + """ + Crea un link de pago en Mercado Pago para marketplace, + incluyendo los fees de Mercado Pago (6.53%) y la comisión propia (3.47%), + los cuales serán cubiertos por el empleador. + """ + + # Validar que tenga access_token + if not employee_account.access_token: + raise Exception("El empleado debe autorizar su cuenta de Mercado Pago primero") + + token = employee_account.access_token + url = f"{settings.MP_API_URL}/checkout/preferences" + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + mp_fee_percent = Decimal(str(MP_FEE_PERCENT)) + app_fee_percent = Decimal(str(APP_FEE_PERCENT)) + + total_amount = amount / (Decimal("1") - (mp_fee_percent + app_fee_percent)) + + # Comisión de tu app (marketplace_fee) + commission = total_amount * app_fee_percent + + preference_data = { + "items": [ + { + "title": concept or "Pago de turno trabajado", + "quantity": 1, + "currency_id": "ARS", + "unit_price": float(total_amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)), + } + ], + "payment_methods": {"installments": 1}, + "back_urls": { + "success": settings.MP_SUCCESS_URL, + "failure": settings.MP_FAILURE_URL, + "pending": settings.MP_PENDING_URL, + }, + "auto_return": "approved", + "marketplace_fee": float(commission.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)), + "metadata": { + "employee_email": employee_account.user.email, + } + } + + if external_reference: + preference_data["external_reference"] = external_reference + + try: + resp = requests.post(url, headers=headers, json=preference_data) + resp.raise_for_status() + except Exception as e: + raise Exception(f"Error en request a MP: {str(e)}") from e + + data = resp.json() + if resp.status_code != 201: + raise Exception(f"Error creando preferencia MP: {data}") + + return data.get("init_point") \ No newline at end of file diff --git a/backend/payments/tests.py b/backend/payments/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/payments/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/payments/urls.py b/backend/payments/urls.py new file mode 100644 index 00000000..15bda444 --- /dev/null +++ b/backend/payments/urls.py @@ -0,0 +1,17 @@ + + +from django.urls import path +from payments.views.payments import GenerateMPStateView, GeneratePaymentLinkView, MercadoPagoAccountAssociatedView, MercadoPagoFeeDetailsView, MercadoPagoOAuthCallbackView, MercadoPagoWebhookView, PaymentCallbackView + + +urlpatterns = [ + path("mercadopago/callback/", MercadoPagoOAuthCallbackView.as_view(), name="mercadopago-callback"), + path("mercadopago/generate-state/", GenerateMPStateView.as_view(), name="mercadopago-generate-state"), + path('mercadopago/payments//', GeneratePaymentLinkView.as_view(), name='mercadopago-generate-payment-link'), + path("success/", PaymentCallbackView.as_view(), name="payment-success"), + path("failure/", PaymentCallbackView.as_view(), name="payment-failure"), + path("pending/", PaymentCallbackView.as_view(), name="payment-pending"), + path("mercadopago/webhook/", MercadoPagoWebhookView.as_view(), name="mercadopago-webhook"), + path("mercadopago/associated/", MercadoPagoAccountAssociatedView.as_view(), name="mercadopago-associated"), + path("mercadopago/fee-details/", MercadoPagoFeeDetailsView.as_view(), name="mercadopago-fee-details"), +] \ No newline at end of file diff --git a/backend/payments/utils.py b/backend/payments/utils.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/payments/views/payments.py b/backend/payments/views/payments.py new file mode 100644 index 00000000..ed8fb7de --- /dev/null +++ b/backend/payments/views/payments.py @@ -0,0 +1,372 @@ +from django.conf import settings +from django.http import HttpResponse +from django.shortcuts import redirect +import requests +from rest_framework import status, permissions, views +from rest_framework.response import Response +from payments.constants import APP_FEE_PERCENT, MP_FEE_PERCENT, PaymentStates +from payments.errors.mercado_pago import INVALID_SIGNATURE, NO_VALID_PAYMENT, PAYMENT_NOT_FOUND, MISSING_MP_CODE, PAYMENT_NOT_FOUND, NO_VALID_PAYMENT +from payments.models.mercado_pago import MercadoPagoAccount +from payments.models.payment_state import PaymentState +from payments.models.payments import Payment +from payments.serializers.payments import GeneratePaymentLinkSerializer, PaymentCallbackSerializer +from payments.services.mercado_pago_service import MercadoPagoService +from user_auth.constants import EMPLOYEE_ROLE, EMPLOYER_ROLE +from decimal import Decimal +import hmac +import hashlib +import requests +import mercadopago +from django.conf import settings +from rest_framework import status, views +from rest_framework.response import Response +import logging + +from user_auth.permissions import IsInGroup +from urllib.parse import urlencode + +logger = logging.getLogger(__name__) + +class MercadoPagoOAuthCallbackView(views.APIView): + """ + Endpoint que recibe el `code` y el `state` de Mercado Pago y guarda los tokens + asociados al usuario correspondiente. Redirige a la app con el estado del flujo. + """ + permission_classes = [] + + def get(self, request, *args, **kwargs): + + code = request.query_params.get("code") + state = request.query_params.get("state") + base_redirect = "jex://employee/profile" + + # helper para construir redirect con query string + def redirect_with(params): + qs = urlencode(params) + response = HttpResponse(status=302) + response["Location"] = f"{base_redirect}?{qs}" + return response + + if not code or not state: + return redirect_with({"status": "error", "reason": "missing_code_or_state"}) + + # Decodificar el JWT temporal para obtener el usuario + try: + user = MercadoPagoService.decode_oauth_state(state) + except Exception as e: + return redirect_with({ + "status": "error", + "reason": "invalid_state", + "detail": str(e) + }) + + # Intercambiar el code por tokens de Mercado Pago + try: + token_data = MercadoPagoService.exchange_code_for_tokens(code) + except Exception as e: + return redirect_with({ + "status": "error", + "reason": "token_exchange_failed", + "detail": str(e) + }) + + # Crear o actualizar la cuenta de Mercado Pago asociada al usuario + try: + account, _ = MercadoPagoAccount.objects.update_or_create( + user=user, + defaults={ + "mp_user_id": token_data.get("user_id"), + "access_token": token_data.get("access_token"), + "refresh_token": token_data.get("refresh_token"), + "public_key": token_data.get("public_key"), + "live_mode": token_data.get("live_mode"), + "expires_in": token_data.get("expires_in"), + "scope": token_data.get("scope"), + }, + ) + except Exception as e: + return redirect_with({ + "status": "error", + "reason": "db_error", + "detail": str(e) + }) + + # Éxito: devolver status=success (incluye user_id si se desea) + return redirect_with({"status": "success", "user_id": user.id}) + + +class GenerateMPStateView(views.APIView): + """ + Endpoint donde el usuario autenticado obtiene un JWT temporal para usar + como `state` en la autorización de Mercado Pago. + """ + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, *args, **kwargs): + user_id = request.user.id + + # Generar JWT temporal para Mercado Pago (state) + state_token = MercadoPagoService.generate_oauth_state(user_id) + + return Response({"state": state_token}, status=status.HTTP_200_OK) + + + +class GeneratePaymentLinkView(views.APIView): + permission_classes = [permissions.IsAuthenticated] + required_groups = [EMPLOYER_ROLE] + + def post(self, request, *args, **kwargs): + serializer = GeneratePaymentLinkSerializer( + data={"offer_id": kwargs.get("offer_id")}, + context={"request": request} + ) + serializer.is_valid(raise_exception=True) + + offer = serializer.validated_data["offer"] + amount = Decimal(serializer.validated_data["amount"]) + concept = serializer.validated_data["concept"] + employee_user = offer.employee.user + + # Calculamos la comisión automáticamente (10% del monto) + commission = (amount * Decimal("0.05")).quantize(Decimal("0.01")) + + pending_state = PaymentState.objects.get(name=PaymentStates.PENDING.value) + + # Traemos la cuenta de Mercado Pago del empleado + try: + mp_account = employee_user.mercado_pago_account + except MercadoPagoAccount.DoesNotExist: + return Response( + {"error": "El empleado no tiene cuenta de Mercado Pago"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Buscar si ya existe un Payment pendiente para la misma oferta y empleado + payment = Payment.objects.filter( + offer=offer, + employee=employee_user, + state=pending_state + ).first() + + if payment: + # Reutilizar el link si ya tenemos mp_payment_id + payment_url = None + if payment.mp_payment_id: + sdk = mercadopago.SDK(settings.MP_ACCESS_TOKEN) + mp_resp = sdk.payment().get(payment.mp_payment_id) + if mp_resp["status"] == 200: + mp_data = mp_resp["response"] + payment_url = mp_data.get( + "transaction_details", {} + ).get("external_resource_url") + if not payment_url: + payment_url = MercadoPagoService.create_payment_link( + employee_account=mp_account, + amount=amount, + concept=concept, + external_reference=str(payment.id) + ) + else: + # Crear nuevo Payment + payment = Payment.objects.create( + offer=offer, + employee=employee_user, + amount=amount, + commission=commission, + concept=concept, + mp_payment_id=None, + state=pending_state + ) + payment_url = MercadoPagoService.create_payment_link( + employee_account=mp_account, + amount=amount, + concept=concept, + external_reference=str(payment.id) + ) + + logger.info(f"Link de pago generado: {payment_url} para payment_id: {payment.id}") + + except Exception as e: + logger.error(f"Error creando preferencia Mercado Pago: {str(e)}") + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response({ + "payment_url": payment_url, + "payment_id": payment.id + }, status=status.HTTP_200_OK) + + +class PaymentCallbackView(views.APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request, *args, **kwargs): + # Obtener parámetros relevantes + preference_id = request.query_params.get("preference_id") + external_reference = request.query_params.get("external_reference") + + # Buscar el Payment + payment = None + if external_reference: + payment = Payment.objects.filter(id=external_reference).first() + elif preference_id: + payment = Payment.objects.filter(mp_payment_id=preference_id).first() + + if not payment: + return Response({"error": "Payment not found"}, status=status.HTTP_404_NOT_FOUND) + + # Determinar tu propio estado + state = payment.state.name.lower() + if state == PaymentStates.APPROVED.value.lower(): + payment_status = "success" + elif state == PaymentStates.PENDING.value.lower(): + payment_status = "pending" + else: + payment_status = "failure" + + # Construir el deeplink con nombre único de parámetro + redirect_url = f"jex://employer/offers?payment_status={payment_status}" + + # Redireccionar + response = HttpResponse(status=302) + response["Location"] = redirect_url + return response + +class MercadoPagoWebhookView(views.APIView): + permission_classes = [permissions.AllowAny] + + def post(self, request, *args, **kwargs): + logger.info("=== MP Webhook received ===") + logger.info("Request headers: %s", request.headers) + logger.info("Request body: %s", request.body.decode('utf-8')) + + # --- 1. Validar firma según template oficial --- + signature_header = request.headers.get("X-Signature", "") + signature_received = None + ts = None + + if signature_header: + try: + parts = signature_header.split(",") + ts = parts[0].split("=")[1] + signature_received = parts[1].split("=")[1] + except IndexError: + logger.warning("Formato de X-Signature inválido: %s", signature_header) + + if not signature_received or not ts: + logger.warning("X-Signature o ts faltante") + return Response({"error": "Invalid signature"}, status=status.HTTP_403_FORBIDDEN) + + # Construir payload según template oficial: id:[data.id_url];request-id:[x-request-id];ts:[ts]; + data_id = request.data.get("data", {}).get("id", "") + request_id = request.headers.get("X-Request-Id", "") + payload = f"id:{data_id};request-id:{request_id};ts:{ts};".encode("utf-8") + + secret = settings.MP_WEBHOOK_SECRET.encode("utf-8") + expected_signature = hmac.new(secret, payload, hashlib.sha256).hexdigest() + + logger.info("Received signature (v1): %s", signature_received) + logger.info("Expected signature: %s", expected_signature) + + if not hmac.compare_digest(signature_received, expected_signature): + logger.warning("Invalid signature for MP webhook") + return Response({"error": "Invalid signature"}, status=status.HTTP_403_FORBIDDEN) + + # --- 2. Validar serializer --- + serializer = PaymentCallbackSerializer(data=request.data) + if not serializer.is_valid(): + logger.warning("Serializer validation failed: %s", serializer.errors) + return Response({"error": "Invalid payload", "details": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + logger.info("Serializer validated successfully") + + # --- 3. Obtener MP payment ID --- + mp_payment_id = request.data.get("data", {}).get("id") + logger.info("MP payment ID from payload: %s", mp_payment_id) + if not mp_payment_id: + logger.warning("MP payment ID missing in payload") + return Response({"error": "MP payment id missing"}, status=status.HTTP_400_BAD_REQUEST) + + # --- 4. Consultar pago en Mercado Pago --- + sdk = mercadopago.SDK(settings.MP_ACCESS_TOKEN) + mp_resp = sdk.payment().get(mp_payment_id) + logger.info("MP API response: %s", mp_resp) + if mp_resp["status"] != 200: + logger.error("Failed to fetch payment from MP API") + return Response({"error": "No valid payment from MP"}, status=status.HTTP_502_BAD_GATEWAY) + + mp_data = mp_resp.get("response", {}) + logger.info("MP payment data: %s", mp_data) + + # --- 5. Buscar Payment en DB --- + try: + payment = Payment.objects.get(mp_payment_id=mp_payment_id) + logger.info("Payment found in DB by mp_payment_id: %s", payment.id) + except Payment.DoesNotExist: + logger.warning("Payment not found by mp_payment_id, trying external_reference") + external_reference = mp_data.get("external_reference") + logger.info("External reference from MP: %s", external_reference) + if external_reference: + try: + payment = Payment.objects.get(id=int(external_reference)) + logger.info("Payment found in DB by external_reference: %s", payment.id) + except (Payment.DoesNotExist, ValueError) as e: + logger.error("Payment not found by external_reference: %s", e) + return Response({"error": "Payment not found"}, status=status.HTTP_404_NOT_FOUND) + else: + logger.error("Payment not found and no external_reference provided") + return Response({"error": "Payment not found"}, status=status.HTTP_404_NOT_FOUND) + + # --- 6. Mapear estado --- + status_name = mp_data.get("status", "pending") + logger.info("MP payment status: %s", status_name) + if status_name == "approved": + payment_state = PaymentStates.APPROVED.value + friendly_status = "Aprobado" + elif status_name in ["pending", "in_process"]: + payment_state = PaymentStates.PENDING.value + friendly_status = "Pendiente" + else: + payment_state = PaymentStates.FAILURE.value + friendly_status = "Fallido" + + # --- 7. Actualizar Payment --- + payment.state = PaymentState.objects.get(name=payment_state) + if not payment.mp_payment_id: + payment.mp_payment_id = mp_payment_id + payment.save() + logger.info("Payment updated successfully: %s - %s", payment.id, payment_state) + + return Response({ + "message": "Pago actualizado vía webhook", + "payment_status": payment_state, + "payment_status_message": friendly_status, + "payment_id": mp_payment_id + }, status=status.HTTP_200_OK) + + +class MercadoPagoAccountAssociatedView(views.APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def get(self, request, *args, **kwargs): + user = request.user + has_account = hasattr(user, "mercado_pago_account") + return Response({"has_account": has_account}, status=status.HTTP_200_OK) + + +class MercadoPagoFeeDetailsView(views.APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + + def get(self, request, *args, **kwargs): + mp_fee_percent = MP_FEE_PERCENT + app_fee_percent = APP_FEE_PERCENT + total_fee_percent = mp_fee_percent + app_fee_percent + return Response({ + "total_fee_percent": round(total_fee_percent * 100, 2), + "mp_fee_percent": round(mp_fee_percent * 100, 2), + "app_fee_percent": round(app_fee_percent * 100, 2) + }, status=status.HTTP_200_OK) + \ No newline at end of file diff --git a/backend/rating/__init__.py b/backend/rating/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/rating/admin.py b/backend/rating/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/rating/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/rating/apps.py b/backend/rating/apps.py new file mode 100644 index 00000000..a0c25fd9 --- /dev/null +++ b/backend/rating/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RatingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'rating' diff --git a/backend/rating/constats.py b/backend/rating/constats.py new file mode 100644 index 00000000..ca4cadbf --- /dev/null +++ b/backend/rating/constats.py @@ -0,0 +1,6 @@ +from enum import Enum + +class PenaltyStates(str, Enum): + IN_REVIEW = "En Revisión" + REJECTED = "Rechazada" + ACCEPTED = "Aceptada" diff --git a/backend/rating/errors/penalty_messages.py b/backend/rating/errors/penalty_messages.py new file mode 100644 index 00000000..7fda780d --- /dev/null +++ b/backend/rating/errors/penalty_messages.py @@ -0,0 +1,7 @@ +USER_NOT_FOUND = "El usuario penalizado no existe." +EVENT_NOT_FOUND = "El evento especificado no existe." +PENALTY_TYPE_NOT_FOUND = "El tipo de penalización no existe." +BEHAVIOR_NOT_FOUND = "El usuario no tiene un registro de comportamiento asociado." +STATE_PENALTY_NOT_FOUND = "El estado de penalización no existe." +PENALTY_STATE_ALREADY_SET = "La sanción ya se encuentra en ese estado." +STATE_PENALTY_NOT_ALLOWED = "No se permite cambiar a ese estado de penalización." \ No newline at end of file diff --git a/backend/rating/errors/rating_menssage.py b/backend/rating/errors/rating_menssage.py new file mode 100644 index 00000000..9c0ec048 --- /dev/null +++ b/backend/rating/errors/rating_menssage.py @@ -0,0 +1,11 @@ +BODY_MUST_BE_ARRAY = "El body debe ser un array de objetos." +RATINGS_SAVED = "Se guardaron las calificaciones correctamente." +SOME_RATINGS_NOT_SAVED = "Algunas calificaciones no se guardaron." +EMPLOYEE_NOT_FOUND = "Empleado no encontrado." +EVENT_NOT_FOUND = "Evento no encontrado." +INVALID_RATING = "Calificación inválida." +PERMISSION_DENIED = "No tienes permiso para realizar esta acción." +RATING_ALREADY_EXISTS = "Ya existe una calificación para este usuario en este evento." +RATER_PROFILE_NOT_FOUND = "No se encontró el perfil correspondiente para el usuario autenticado." + +EMPLOYER_NOT_FOUND= "Empleador {employer_id} no encontrado" \ No newline at end of file diff --git a/backend/rating/migrations/0001_initial.py b/backend/rating/migrations/0001_initial.py new file mode 100644 index 00000000..ff9894ec --- /dev/null +++ b/backend/rating/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.23 on 2025-10-01 16:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('eventos', '0005_event_event_image'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Behavior', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('average_rating', models.FloatField(default=0.0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='behaviors', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='StatePenalty', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name='Rating', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating', models.FloatField()), + ('comments', models.TextField(blank=True)), + ('date', models.DateTimeField(auto_now_add=True)), + ('behavior', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='rating.behavior')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='eventos.event')), + ('rater', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_ratings', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='Penalty', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comments', models.TextField(blank=True)), + ('penalty_date', models.DateTimeField(auto_now_add=True)), + ('behavior', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='penalties', to='rating.behavior')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='penalties', to='eventos.event')), + ('penalty_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='penalty_states', to='rating.statepenalty')), + ('punisher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_penalties', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-penalty_date'], + }, + ), + ] diff --git a/backend/rating/migrations/0002_populate_penalty_states.py b/backend/rating/migrations/0002_populate_penalty_states.py new file mode 100644 index 00000000..be91a273 --- /dev/null +++ b/backend/rating/migrations/0002_populate_penalty_states.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.23 on 2025-10-01 18:50 +from django.db import migrations + +def populate_penalty_states(apps, schema_editor): + from rating.constats import PenaltyStates + + StatePenalty = apps.get_model('rating', 'StatePenalty') + for state in PenaltyStates: + StatePenalty.objects.get_or_create(name=state.value) + +class Migration(migrations.Migration): + + dependencies = [ + ('rating', '0001_initial'), # O la migración anterior a esta + ] + + operations = [ + migrations.RunPython(populate_penalty_states), + ] \ No newline at end of file diff --git a/backend/rating/migrations/0003_alter_behavior_average_rating.py b/backend/rating/migrations/0003_alter_behavior_average_rating.py new file mode 100644 index 00000000..e6b7b45c --- /dev/null +++ b/backend/rating/migrations/0003_alter_behavior_average_rating.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.23 on 2025-10-01 22:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rating', '0002_populate_penalty_states'), + ] + + operations = [ + migrations.AlterField( + model_name='behavior', + name='average_rating', + field=models.FloatField(blank=True, default=None, null=True), + ), + ] diff --git a/backend/rating/migrations/0004_populate_behavior.py b/backend/rating/migrations/0004_populate_behavior.py new file mode 100644 index 00000000..63fb133e --- /dev/null +++ b/backend/rating/migrations/0004_populate_behavior.py @@ -0,0 +1,26 @@ +from django.db import migrations +from django.utils import timezone + +def create_behavior_for_users(apps, schema_editor): + CustomUser = apps.get_model('user_auth', 'CustomUser') # O 'user_auth', 'CustomUser' si ese es el nombre correcto + Behavior = apps.get_model('rating', 'Behavior') + now = timezone.now() + + for user in CustomUser.objects.all(): + Behavior.objects.get_or_create( + user_id=user.id, + defaults={ + 'average_rating': None, + 'created_at': now, + } + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('rating', '0003_alter_behavior_average_rating'), +] + + operations = [ + migrations.RunPython(create_behavior_for_users), + ] \ No newline at end of file diff --git a/backend/rating/migrations/0005_userconnection.py b/backend/rating/migrations/0005_userconnection.py new file mode 100644 index 00000000..babfd171 --- /dev/null +++ b/backend/rating/migrations/0005_userconnection.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.23 on 2025-10-05 11:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("rating", "0004_populate_behavior"), + ] + + operations = [ + migrations.CreateModel( + name="UserConnection", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="employer_relations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "employer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="employee_relations", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + "unique_together": {("employee", "employer")}, + }, + ), + ] diff --git a/backend/rating/migrations/0006_penaltycategory_penaltytype_penalty_penalty_type.py b/backend/rating/migrations/0006_penaltycategory_penaltytype_penalty_penalty_type.py new file mode 100644 index 00000000..e4c12951 --- /dev/null +++ b/backend/rating/migrations/0006_penaltycategory_penaltytype_penalty_penalty_type.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.23 on 2025-10-18 11:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("rating", "0005_userconnection"), + ] + + operations = [ + migrations.CreateModel( + name="PenaltyCategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ], + ), + migrations.CreateModel( + name="PenaltyType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="types", + to="rating.penaltycategory", + ), + ), + ], + options={ + "unique_together": {("category", "name")}, + }, + ), + migrations.AddField( + model_name="penalty", + name="penalty_type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="penalties", + to="rating.penaltytype", + ), + ), + ] diff --git a/backend/rating/migrations/0007_populate_penalty_types.py b/backend/rating/migrations/0007_populate_penalty_types.py new file mode 100644 index 00000000..5b55a380 --- /dev/null +++ b/backend/rating/migrations/0007_populate_penalty_types.py @@ -0,0 +1,51 @@ +from django.db import migrations + +def create_penalty_categories(apps, schema_editor): + PenaltyCategory = apps.get_model('rating', 'PenaltyCategory') + PenaltyType = apps.get_model('rating', 'PenaltyType') + + # --- Categoría 1: Compromiso y puntualidad --- + compromiso = PenaltyCategory.objects.create(name="Compromiso y puntualidad") + PenaltyType.objects.bulk_create([ + PenaltyType(category=compromiso, name="Llega tarde"), + PenaltyType(category=compromiso, name="No confirma asistencia"), + PenaltyType(category=compromiso, name="Trabaja desinteresadamente"), + ]) + + # --- Categoría 2: Responsabilidad laboral --- + responsabilidad = PenaltyCategory.objects.create(name="Responsabilidad laboral") + PenaltyType.objects.bulk_create([ + PenaltyType(category=responsabilidad, name="No presentarse al turno"), + PenaltyType(category=responsabilidad, name="Abandonar turno"), + PenaltyType(category=responsabilidad, name="No cumplir funciones encomendadas"), + ]) + + # --- Categoría 3: Conducta inadecuada --- + conducta = PenaltyCategory.objects.create(name="Conducta inadecuada") + PenaltyType.objects.bulk_create([ + PenaltyType(category=conducta, name="Conducta violenta"), + PenaltyType(category=conducta, name="Fraude"), + PenaltyType(category=conducta, name="Robo"), + ]) + + # --- Categoría 4: Otro --- + otro = PenaltyCategory.objects.create(name="Otro") + PenaltyType.objects.create(category=otro, name="Otro") + + +def remove_penalty_categories(apps, schema_editor): + PenaltyType = apps.get_model('rating', 'PenaltyType') + PenaltyCategory = apps.get_model('rating', 'PenaltyCategory') + PenaltyType.objects.all().delete() + PenaltyCategory.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("rating", "0006_penaltycategory_penaltytype_penalty_penalty_type"), + ] + + operations = [ + migrations.RunPython(create_penalty_categories, remove_penalty_categories), + ] diff --git a/backend/rating/migrations/0008_penaltystatehistory.py b/backend/rating/migrations/0008_penaltystatehistory.py new file mode 100644 index 00000000..3c79bc45 --- /dev/null +++ b/backend/rating/migrations/0008_penaltystatehistory.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.23 on 2026-03-01 12:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('rating', '0007_populate_penalty_types'), + ] + + operations = [ + migrations.CreateModel( + name='PenaltyStateHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('comment', models.TextField(blank=True)), + ('changed_at', models.DateTimeField(auto_now_add=True)), + ('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='penalty_state_changes', to=settings.AUTH_USER_MODEL)), + ('penalty', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state_history', to='rating.penalty')), + ('state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history_entries', to='rating.statepenalty')), + ], + options={ + 'ordering': ['-changed_at'], + }, + ), + ] diff --git a/backend/rating/migrations/__init__.py b/backend/rating/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/rating/models/__init__.py b/backend/rating/models/__init__.py new file mode 100644 index 00000000..8bfad213 --- /dev/null +++ b/backend/rating/models/__init__.py @@ -0,0 +1,17 @@ +from .behavior import Behavior +from .penalty import Penalty +from .rating import Rating +from .state_penalty import StatePenalty +from .users_connections import UserConnection +from .penalty_type import PenaltyType +from .penalty_category import PenaltyCategory + +__all__ = [ + 'Behavior', + 'Penalty', + 'Rating', + 'StatePenalty', + 'UserConnection', + 'PenaltyType', + 'PenaltyCategory', +] \ No newline at end of file diff --git a/backend/rating/models/behavior.py b/backend/rating/models/behavior.py new file mode 100644 index 00000000..57efc067 --- /dev/null +++ b/backend/rating/models/behavior.py @@ -0,0 +1,19 @@ +from django.db import models +from django.db.models import Avg +from user_auth.models import CustomUser # Importa tu modelo de usuario + +class Behavior(models.Model): + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="behaviors") + average_rating = models.FloatField(default=None, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"Behavior of {self.user.email}" + + def update_average_rating(self): + avg = self.ratings.aggregate(Avg('rating'))['rating__avg'] or 0 + self.average_rating = avg + self.save() \ No newline at end of file diff --git a/backend/rating/models/penalty.py b/backend/rating/models/penalty.py new file mode 100644 index 00000000..3966d24a --- /dev/null +++ b/backend/rating/models/penalty.py @@ -0,0 +1,22 @@ +from django.db import models +from rating.models.penalty_type import PenaltyType +from user_auth.models import CustomUser +from rating.models.behavior import Behavior +from rating.models.state_penalty import StatePenalty +from eventos.models.event import Event + +class Penalty(models.Model): + behavior = models.ForeignKey(Behavior, on_delete=models.CASCADE, related_name="penalties") + punisher = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="given_penalties") + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="penalties") + comments = models.TextField(blank=True) + penalty_state = models.ForeignKey(StatePenalty, on_delete=models.CASCADE, related_name="penalty_states") + penalty_type = models.ForeignKey(PenaltyType, on_delete=models.CASCADE, related_name="penalties", null=True, blank=True) + penalty_date = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-penalty_date'] + + def __str__(self): + return f"Penalty for {self.behavior.user.email} ({self.penalty_type})" + \ No newline at end of file diff --git a/backend/rating/models/penalty_category.py b/backend/rating/models/penalty_category.py new file mode 100644 index 00000000..58bb8bcc --- /dev/null +++ b/backend/rating/models/penalty_category.py @@ -0,0 +1,10 @@ +from django.db import models + + +class PenaltyCategory(models.Model): + name = models.CharField(max_length=100, unique=True) + + def __str__(self): + return self.name + + diff --git a/backend/rating/models/penalty_story_state.py b/backend/rating/models/penalty_story_state.py new file mode 100644 index 00000000..0a25bf13 --- /dev/null +++ b/backend/rating/models/penalty_story_state.py @@ -0,0 +1,33 @@ +from django.db import models + +from rating.models.penalty import Penalty +from rating.models.state_penalty import StatePenalty +from user_auth.models.user import CustomUser + +class PenaltyStateHistory(models.Model): + penalty = models.ForeignKey( + Penalty, + on_delete=models.CASCADE, + related_name="state_history" + ) + + state = models.ForeignKey( + StatePenalty, + on_delete=models.CASCADE, + related_name="history_entries" + ) + + changed_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="penalty_state_changes" + ) + + comment = models.TextField(blank=True) + + changed_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-changed_at'] \ No newline at end of file diff --git a/backend/rating/models/penalty_type.py b/backend/rating/models/penalty_type.py new file mode 100644 index 00000000..02c842c6 --- /dev/null +++ b/backend/rating/models/penalty_type.py @@ -0,0 +1,14 @@ +from django.db import models + +from rating.models.penalty_category import PenaltyCategory + + +class PenaltyType(models.Model): + category = models.ForeignKey(PenaltyCategory, on_delete=models.CASCADE, related_name="types") + name = models.CharField(max_length=100) + + class Meta: + unique_together = ('category', 'name') + + def __str__(self): + return f"{self.category.name} - {self.name}" \ No newline at end of file diff --git a/backend/rating/models/rating.py b/backend/rating/models/rating.py new file mode 100644 index 00000000..b1a43026 --- /dev/null +++ b/backend/rating/models/rating.py @@ -0,0 +1,19 @@ +from multiprocessing import Event +from django.db import models +from user_auth.models import CustomUser # Importa el modelo de usuario +from rating.models.behavior import Behavior +from eventos.models.event import Event + +class Rating(models.Model): + behavior = models.ForeignKey(Behavior, on_delete=models.CASCADE, related_name="ratings") + rater = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="given_ratings") + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="ratings") + rating = models.FloatField() + comments = models.TextField(blank=True) + date = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-date'] + + def __str__(self): + return f"Rating {self.rating} for {self.behavior.user.email} by {self.rater.email}" \ No newline at end of file diff --git a/backend/rating/models/state_penalty.py b/backend/rating/models/state_penalty.py new file mode 100644 index 00000000..0153a410 --- /dev/null +++ b/backend/rating/models/state_penalty.py @@ -0,0 +1,7 @@ +from django.db import models + +class StatePenalty(models.Model): + name = models.CharField(max_length=100, null=False, blank=False) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/backend/rating/models/users_connections.py b/backend/rating/models/users_connections.py new file mode 100644 index 00000000..01efc6ad --- /dev/null +++ b/backend/rating/models/users_connections.py @@ -0,0 +1,22 @@ +from django.db import models +from user_auth.models import CustomUser + +class UserConnection(models.Model): + employee = models.ForeignKey( + CustomUser, + on_delete=models.CASCADE, + related_name="employer_relations" + ) + employer = models.ForeignKey( + CustomUser, + on_delete=models.CASCADE, + related_name="employee_relations" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('employee', 'employer') + ordering = ['-created_at'] + + def __str__(self): + return f"Employee {self.employee.email} linked to Employer {self.employer.email}" \ No newline at end of file diff --git a/backend/rating/serializers/penalty.py b/backend/rating/serializers/penalty.py new file mode 100644 index 00000000..c0298a1e --- /dev/null +++ b/backend/rating/serializers/penalty.py @@ -0,0 +1,266 @@ + + +from eventos.formatters.date_time import CustomDateField +from eventos.models.event import Event +from rating.constats import PenaltyStates +from rating.errors.penalty_messages import BEHAVIOR_NOT_FOUND, EVENT_NOT_FOUND, PENALTY_STATE_ALREADY_SET, PENALTY_TYPE_NOT_FOUND, STATE_PENALTY_NOT_ALLOWED, STATE_PENALTY_NOT_FOUND, USER_NOT_FOUND +from rating.models.behavior import Behavior +from rating.models.penalty import Penalty +from rating.models.penalty_category import PenaltyCategory +from rating.models.penalty_story_state import PenaltyStateHistory +from rating.models.penalty_type import PenaltyType +from rest_framework import serializers + +from rating.models.state_penalty import StatePenalty +from user_auth.models.user import CustomUser +from django.db import transaction + +from vacancies.formatters.date_time import CustomTimeField + + + +class PenaltyTypeSerializer(serializers.ModelSerializer): + class Meta: + model = PenaltyType + fields = ['id', 'name'] + + +class PenaltyCategorySerializer(serializers.ModelSerializer): + types = PenaltyTypeSerializer(many=True, read_only=True) + + class Meta: + model = PenaltyCategory + fields = ['id', 'name', 'types'] + + +class CreatePenaltySerializer(serializers.Serializer): + penalized_user = serializers.IntegerField(required=True) + event = serializers.IntegerField(required=True) + penalty_type = serializers.IntegerField(required=True) + comments = serializers.CharField(required=False, allow_blank=True) + + def validate(self, data): + # Validar usuario penalizado + try: + penalized_user = CustomUser.objects.get(pk=data['penalized_user']) + except CustomUser.DoesNotExist: + raise serializers.ValidationError(USER_NOT_FOUND) + + # Validar evento + try: + event_obj = Event.objects.get(pk=data['event']) + except Event.DoesNotExist: + raise serializers.ValidationError(EVENT_NOT_FOUND) + + # Validar tipo de penalización + try: + penalty_type_obj = PenaltyType.objects.get(pk=data['penalty_type']) + except PenaltyType.DoesNotExist: + raise serializers.ValidationError(PENALTY_TYPE_NOT_FOUND) + + # Buscar el Behavior del usuario penalizado + try: + behavior_obj = Behavior.objects.get(user=penalized_user) + except Behavior.DoesNotExist: + raise serializers.ValidationError(BEHAVIOR_NOT_FOUND) + + # Guardar objetos validados + data['penalized_user_obj'] = penalized_user + data['event_obj'] = event_obj + data['penalty_type_obj'] = penalty_type_obj + data['behavior_obj'] = behavior_obj + return data + + def create(self, validated_data): + request_user = self.context['request'].user # punisher + behavior = validated_data['behavior_obj'] + penalty_type = validated_data['penalty_type_obj'] + event = validated_data['event_obj'] + comments = validated_data.get('comments', '') + + # Estado inicial de la penalización + in_review_state = PenaltyStates.IN_REVIEW.value + state_in_review = StatePenalty.objects.filter(name=in_review_state).first() + + penalty = Penalty.objects.create( + behavior=behavior, + punisher=request_user, + event=event, + comments=comments, + penalty_state=state_in_review, + penalty_type=penalty_type + ) + return penalty + +class PenalizedUserSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ['first_name', 'last_name', 'phone', 'email'] + + +class PenaltySerializer(serializers.ModelSerializer): + penalized_user = serializers.SerializerMethodField() + event_info = serializers.SerializerMethodField() + penalty_state = serializers.CharField(source='penalty_state.name') + penalty_type = serializers.CharField(source='penalty_type.name') + + class Meta: + model = Penalty + fields = [ + 'id', + 'penalized_user', + 'punisher', + 'event_info', + 'comments', + 'penalty_state', + 'penalty_type', + 'penalty_date', + ] + + def get_penalized_user(self, obj): + user = obj.behavior.user + return PenalizedUserSerializer(user).data + + def get_event_info(self, obj): + event = obj.event + image_url = event.event_image.url if event.event_image else None + return { + 'name': event.name, + 'image': image_url + } + +class UpdatePenaltyStatusSerializer(serializers.Serializer): + + state_id = serializers.IntegerField(required=True) + comment = serializers.CharField(required=False, allow_blank=True) + + def validate_state_id(self, value): + + try: + state_obj = StatePenalty.objects.get(pk=value) + except StatePenalty.DoesNotExist: + raise serializers.ValidationError(STATE_PENALTY_NOT_FOUND) + + valid_states = [state.value for state in PenaltyStates] + if state_obj.name not in valid_states: + raise serializers.ValidationError(STATE_PENALTY_NOT_ALLOWED) + + self.context["state_obj"] = state_obj + return value + + def update(self, instance, validated_data): + + request_user = self.context["request"].user + new_state = self.context["state_obj"] + comment = validated_data.get("comment", "") + + if instance.penalty_state_id == new_state.id: + raise serializers.ValidationError(PENALTY_STATE_ALREADY_SET) + + with transaction.atomic(): + + instance.penalty_state = new_state + instance.save(update_fields=["penalty_state"]) + + PenaltyStateHistory.objects.create( + penalty=instance, + state=new_state, + changed_by=request_user, + comment=comment + ) + + return instance + + +class AdminPenaltySerializer(serializers.ModelSerializer): + + penalized_user = serializers.SerializerMethodField() + event_info = serializers.SerializerMethodField() + + penalty_state = serializers.CharField(source="penalty_state.name") + penalty_type = serializers.CharField(source="penalty_type.name") + + penalty_date = CustomDateField(read_only=True) + penalty_time = CustomTimeField(source="penalty_date", read_only=True) + + close_date = serializers.SerializerMethodField() + close_time = serializers.SerializerMethodField() + state_changed_by = serializers.SerializerMethodField() + state_comment = serializers.SerializerMethodField() + + class Meta: + model = Penalty + fields = [ + "id", + "penalized_user", + "punisher", + "event_info", + "comments", + "penalty_state", + "penalty_type", + "penalty_date", + "penalty_time", + "close_date", + "close_time", + "state_changed_by", + "state_comment", + ] + + + def _get_terminal_history(self, obj): + + if hasattr(obj, "_terminal_history"): + return obj._terminal_history + + terminal_states = { + PenaltyStates.ACCEPTED.value, + PenaltyStates.REJECTED.value, + } + + for history in obj.state_history.all(): + if history.state.name in terminal_states: + obj._terminal_history = history + return history + + obj._terminal_history = None + return None + + + def get_penalized_user(self, obj): + return PenalizedUserSerializer(obj.behavior.user).data + + def get_event_info(self, obj): + event = obj.event + return { + "name": event.name, + "image": event.event_image.url if event.event_image else None + } + + def get_close_date(self, obj): + history = self._get_terminal_history(obj) + if not history: + return None + return CustomDateField().to_representation(history.changed_at.date()) + + def get_close_time(self, obj): + history = self._get_terminal_history(obj) + if not history: + return None + return CustomTimeField().to_representation(history.changed_at.time()) + + def get_state_changed_by(self, obj): + history = self._get_terminal_history(obj) + if not history or not history.changed_by: + return None + return { + "id": history.changed_by.id, + "email": history.changed_by.email, + "first_name": history.changed_by.first_name, + "last_name": history.changed_by.last_name, + } + + def get_state_comment(self, obj): + history = self._get_terminal_history(obj) + if not history: + return None + return history.comment \ No newline at end of file diff --git a/backend/rating/serializers/rating.py b/backend/rating/serializers/rating.py new file mode 100644 index 00000000..0160f591 --- /dev/null +++ b/backend/rating/serializers/rating.py @@ -0,0 +1,467 @@ +from rest_framework import serializers +from applications.constants import OfferStates +from applications.models.offer_state import OfferState +from rating.models import Rating, Behavior +from rating.models.users_connections import UserConnection +from user_auth.models import CustomUser +from user_auth.models.employer import EmployerProfile +from eventos.models.event import Event +from eventos.formatters.date_time import CustomDateField, CustomTimeField +from applications.models import Offer +from applications.utils import get_job_type_display +from rating.utils import has_already_rated +from rating.errors.rating_menssage import ( + EMPLOYER_NOT_FOUND, + RATING_ALREADY_EXISTS, + EVENT_NOT_FOUND, +) +from rating.errors.rating_menssage import EMPLOYEE_NOT_FOUND + + +class SingleRatingSerializer(serializers.Serializer): + employee = serializers.IntegerField(required=True) + rating = serializers.FloatField(required=True) + comments = serializers.CharField(required=False, allow_blank=True) + event = serializers.IntegerField(required=True) + link = serializers.BooleanField(required=False, default=None) + + def create(self, validated_data): + request = self.context['request'] + rater = request.user + # Use objects attached by validate (employee_user, event_obj) if present + employee_user = validated_data.get('employee_user') + event = validated_data.get('event_obj') + if not employee_user or not event: + # fallback to fetch (defensive) + employee_id = validated_data['employee'] + event_id = validated_data['event'] + try: + employee_user = CustomUser.objects.get(pk=employee_id) + except CustomUser.DoesNotExist: + raise serializers.ValidationError(EMPLOYEE_NOT_FOUND) + try: + event = Event.objects.get(pk=event_id) + except Event.DoesNotExist: + raise serializers.ValidationError(EVENT_NOT_FOUND) + + # Busca o crea el Behavior + behavior, _ = Behavior.objects.get_or_create(user=employee_user) + + # Crea el rating + rating = Rating.objects.create( + behavior=behavior, + rater=rater, + event=event, + rating=validated_data['rating'], + comments=validated_data.get('comments', "") + ) + behavior.update_average_rating() + + return rating + + def validate(self, data): + """Validate employee and event exist, and that the rater hasn't already rated. + + Attaches 'employee_user' and 'event_obj' to the validated data for reuse. + """ + employee_id = data.get('employee') + event_id = data.get('event') + + # Validate employee exists + try: + employee_user = CustomUser.objects.get(pk=employee_id) + except CustomUser.DoesNotExist: + raise serializers.ValidationError(EMPLOYEE_NOT_FOUND) + + # Validate event exists + try: + event_obj = Event.objects.get(pk=event_id) + except Event.DoesNotExist: + raise serializers.ValidationError(EVENT_NOT_FOUND) + + # Check duplicate rating + request = self.context.get('request') + rater = getattr(request, 'user', None) + if rater and Rating.objects.filter( + behavior__user=employee_user, + rater=rater, + event_id=event_id, + ).exists(): + raise serializers.ValidationError(RATING_ALREADY_EXISTS) + + data['employee_user'] = employee_user + data['event_obj'] = event_obj + return data + + +class ViewRatingsSerializer(serializers.ModelSerializer): + user_full_name = serializers.SerializerMethodField() + average_rating = serializers.FloatField() + image_url = serializers.SerializerMethodField() + + class Meta: + model = Behavior + fields = [ + 'average_rating', + 'user_full_name', + 'image_url', + ] + + def get_user_full_name(self, obj): + + user = obj.user + + if hasattr(user, "employee_profile"): + return f"{user.first_name} {user.last_name}" + + if hasattr(user, "employer_profile"): + return user.employer_profile.company_name + + def get_image_url(self, obj): + user = obj.user + return user.profile_image.url if user.profile_image else None + + + +class ListEmployerEventsSerializer(serializers.ModelSerializer): + employer_id = serializers.IntegerField( + source=( + "selected_shift.vacancy." + "event.owner.id" + ) + ) + company_name = serializers.SerializerMethodField() + employer_full_name = serializers.SerializerMethodField() + job_type = serializers.SerializerMethodField() + event_id = serializers.IntegerField( + source=("selected_shift.vacancy." "event.id") + ) + event_name = serializers.CharField( + source=("selected_shift.vacancy." "event.name") + ) + event_start_date = CustomDateField( + source=("selected_shift.vacancy." "event.start_date"), read_only=True + ) + event_end_date = CustomDateField( + source=("selected_shift.vacancy." "event.end_date"), read_only=True + ) + event_start_time = CustomTimeField( + source=("selected_shift.vacancy." "event.start_time"), read_only=True + ) + event_end_time = CustomTimeField( + source=("selected_shift.vacancy." "event.end_time"), read_only=True + ) + already_rated = serializers.SerializerMethodField() + + image_url = serializers.SerializerMethodField() + + class Meta: + model = Offer + fields = [ + "employer_id", + "company_name", + "employer_full_name", + "job_type", + "event_id", + "event_name", + "event_start_date", + "event_start_time", + "event_end_date", + "event_end_time", + "already_rated", + "image_url" + ] + + def get_owner_full_name(self, obj): + owner = obj.selected_shift.vacancy.event.owner + return f"{owner.first_name} {owner.last_name}" + + # rename helper to match field name `employer_full_name` + def get_employer_full_name(self, obj): + owner = obj.selected_shift.vacancy.event.owner + return f"{owner.first_name} {owner.last_name}" + + def get_company_name(self, obj): + owner_user = obj.selected_shift.vacancy.event.owner + try: + employer_profile = owner_user.employer_profile + return employer_profile.company_name + except EmployerProfile.DoesNotExist: + return None + + def get_job_type(self, obj): + return get_job_type_display(obj.selected_shift.vacancy) + + def get_already_rated(self, obj): + request = self.context.get('request') + # owner is the employer user for the event + owner_user = obj.selected_shift.vacancy.event.owner + # current user (the rater) may be anonymous in some contexts + rater = getattr(request, 'user', None) if request is not None else None + + # event instance + event = obj.selected_shift.vacancy.event + + return has_already_rated( + event=event, rater=rater, rated_user=owner_user + ) + + def get_image_url(self, obj): + owner = obj.selected_shift.vacancy.event.owner + return owner.profile_image.url if owner.profile_image else None + +class SingleEmployerRatingSerializer(serializers.Serializer): + employer = serializers.IntegerField(required=True) + rating = serializers.FloatField(required=True) + comments = serializers.CharField(required=False, allow_blank=True) + event = serializers.IntegerField(required=True) + + def validate(self, data): + employer_id = data['employer'] + event_id = data['event'] + # Validate employer exists + try: + employer_user = CustomUser.objects.get(pk=employer_id) + except CustomUser.DoesNotExist: + raise serializers.ValidationError( + EMPLOYER_NOT_FOUND.format(employer_id=employer_id) + ) + + # Validate event exists + try: + event_obj = Event.objects.get(pk=event_id) + except Event.DoesNotExist: + raise serializers.ValidationError(EVENT_NOT_FOUND) + + # Chequea si ya existe un rating de este empleado a este empleador + # para este evento + if Rating.objects.filter( + behavior__user=employer_user, + rater=self.context['request'].user, + event_id=event_id, + ).exists(): + raise serializers.ValidationError(RATING_ALREADY_EXISTS) + + # Attach found objects for use by the view + data['employer_user'] = employer_user + data['event_obj'] = event_obj + + return data + + +class EmployeeRatingDetailSerializer(serializers.ModelSerializer): + company_name = serializers.SerializerMethodField() + reviewerImageUrl = serializers.SerializerMethodField() + job_type = serializers.SerializerMethodField() + score = serializers.FloatField(source='rating') + comment = serializers.CharField(source='comments') + job_date = CustomDateField(source='get_job_date', read_only=True) + createdAt = CustomDateField(source='date', read_only=True) + + class Meta: + model = Rating + fields = [ + 'id', + 'company_name', + 'reviewerImageUrl', + 'job_type', + 'score', + 'comment', + 'job_date', + 'createdAt', + ] + + def get_company_name(self, obj): + owner_user = obj.event.owner + try: + employer_profile = owner_user.employer_profile + return employer_profile.company_name + except EmployerProfile.DoesNotExist: + return None + + def _get_offer_for_rating(self, obj): + # Cache offers per-rating to avoid duplicate queries when both + # get_job_type and get_job_date are called. + if not hasattr(self, "_offer_cache"): + self._offer_cache = {} + + if obj.id in self._offer_cache: + return self._offer_cache[obj.id] + + state_names = [ + OfferStates.ACCEPTED.value, + OfferStates.COMPLETED.value, + OfferStates.NOT_SHOWN.value, + ] + + employee_profile = getattr(obj.behavior.user, "employee_profile", None) + if not employee_profile: + self._offer_cache[obj.id] = None + return None + + offer = ( + Offer.objects + .select_related("selected_shift__vacancy") + .filter( + selected_shift__vacancy__event=obj.event, + employee__id=employee_profile.id, + state__name__in=state_names, + ) + .first() + ) + + self._offer_cache[obj.id] = offer + return offer + + def get_job_type(self, obj): + offer = self._get_offer_for_rating(obj) + print(offer) + return get_job_type_display(offer.selected_shift.vacancy) if offer else None + + def get_job_date(self, obj): + # Usar el start_date del selected_shift de la primera oferta encontrada + offer = self._get_offer_for_rating(obj) + if not offer: + return None + + shift = getattr(offer, "selected_shift", None) + return getattr(shift, "start_date", None) + + + def get_reviewerImageUrl(self, obj): + image = getattr(obj.rater, 'profile_image', None) + return image.url if image else None + +class EmployerRatingDetailSerializer(serializers.ModelSerializer): + employee_name = serializers.SerializerMethodField() + employee_image_url = serializers.SerializerMethodField() + event_name = serializers.CharField(source="event.name") + job_type = serializers.SerializerMethodField() + score = serializers.FloatField(source='rating') + comment = serializers.CharField(source='comments') + job_date = CustomDateField(source='get_job_date', read_only=True) + created_at = CustomDateField(source='date', read_only=True) + + class Meta: + model = Rating + fields = [ + "id", + "employee_name", + "employee_image_url", + "event_name", + "job_type", + "score", + "comment", + "job_date", + "created_at" + ] + + def get_employee_name(self, obj): + user = obj.rater + return f"{user.first_name} {user.last_name}".strip() + + def get_employee_image_url(self, obj): + user = obj.rater + image = getattr(user, "profile_image", None) + return image.url if image else None + + def _get_offer_for_rating(self, obj): + if not hasattr(self, "_offer_cache"): + self._offer_cache = {} + + if obj.id in self._offer_cache: + return self._offer_cache[obj.id] + + state_names = [ + OfferStates.ACCEPTED.value, + OfferStates.COMPLETED.value, + OfferStates.NOT_SHOWN.value, + ] + + # El empleado es el que calificó → rater + employee_profile = getattr(obj.rater, "employee_profile", None) + if not employee_profile: + self._offer_cache[obj.id] = None + return None + + offer = ( + Offer.objects + .select_related("selected_shift__vacancy") + .filter( + selected_shift__vacancy__event=obj.event, + employee_id=employee_profile.id, + state__name__in=state_names, + ) + .first() + ) + + self._offer_cache[obj.id] = offer + return offer + + def get_job_type(self, obj): + offer = self._get_offer_for_rating(obj) + return get_job_type_display(offer.selected_shift.vacancy) if offer else None + + def get_job_date(self, obj): + offer = self._get_offer_for_rating(obj) + if not offer: + return None + + shift = getattr(offer, "selected_shift", None) + return getattr(shift, "start_date", None) + +class ListRatingsEmployeeSerializer(serializers.ModelSerializer): + event_name = serializers.CharField(source="event.name") + rater = serializers.SerializerMethodField() + rater_image = serializers.SerializerMethodField() + date = CustomDateField() + + + class Meta: + model = Rating + fields = [ + "rating", + "comments", + "date", + "event_name", + "rater", + "rater_image" + ] + + def get_rater(self, obj): + try: + return obj.rater.employer_profile.company_name + except EmployerProfile.DoesNotExist: + return None + + def get_rater_image(self, obj): + if obj.rater.profile_image: + return obj.rater.profile_image.url + return None + + +class ListRatingsEmployerSerializer(serializers.ModelSerializer): + event_name = serializers.CharField(source="event.name") + rater = serializers.SerializerMethodField() + rater_image = serializers.SerializerMethodField() + date = CustomDateField() + + class Meta: + model = Rating + fields = [ + "rating", + "comments", + "date", + "event_name", + "rater", + "rater_image" + ] + + def get_rater(self, obj): + user = obj.rater + return f"{user.first_name} {user.last_name}".strip() + + def get_rater_image(self, obj): + if obj.rater.profile_image: + return obj.rater.profile_image.url + return None diff --git a/backend/rating/tests.py b/backend/rating/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/rating/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/rating/urls.py b/backend/rating/urls.py new file mode 100644 index 00000000..f4692932 --- /dev/null +++ b/backend/rating/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from rating.views.penalty import CreatePenaltyView, ListPenaltyCategoriesView, UpdatePenaltyStatusView +from rating.views.rating import BulkCreateRatingView, EmployeeRatingDetailView, EmployerRatingDetailView, ListRatingsEmployeeView, ListRatingsEmployerView, ViewRatings, ListEmployersEventsView, BulkCreateEmployerRatingView + +urlpatterns = [ + path('rate/', BulkCreateRatingView.as_view(), name='bulk-realize-rating'), + path('employers/torating/', ListEmployersEventsView.as_view(), name='list-employers-to-rating'), + path('viewratings//', ViewRatings.as_view(), name='list-participation-employee'), + path('rate-employer/', BulkCreateEmployerRatingView.as_view(), name='bulk-realize-employer-rating'), + path('penalty/categories/', ListPenaltyCategoriesView.as_view(), name='list-penalty-categories'), + path('penalty/create/', CreatePenaltyView.as_view(), name='create-penalty'), + path('employee/ratings/', EmployeeRatingDetailView.as_view(), name='employee-rating-detail'), + path('employer/ratings/', EmployerRatingDetailView.as_view(), name='employer-rating-detail'), + path('employee/ratings//', ListRatingsEmployeeView.as_view(), name='employee-rating-detail-by-id'), + path('employer/ratings//', ListRatingsEmployerView.as_view(), name='employer-rating-detail-by-id'), + path("penalty/update-state//", UpdatePenaltyStatusView.as_view(), name="change-penalty-state"), +] \ No newline at end of file diff --git a/backend/rating/utils.py b/backend/rating/utils.py new file mode 100644 index 00000000..c8981ff8 --- /dev/null +++ b/backend/rating/utils.py @@ -0,0 +1,31 @@ +from typing import Union + +from rating.models import Rating +from rating.models.behavior import Behavior + + +def has_already_rated(event: Union[int, object], rater, rated_user) -> bool: + if not rater or not rated_user: + return False + + # Accept either event instance or event id + event_id = getattr(event, "id", event) + + try: + return Rating.objects.filter( + behavior__user=rated_user, + rater=rater, + event_id=event_id, + ).exists() + except Exception: + # On unexpected error, behave as if not rated to avoid blocking reads + return False + +def get_user_average_rating(user): + user_id = getattr(user, "id", user) + behavior = Behavior.objects.filter(user_id=user_id).first() + return behavior.average_rating if behavior else None + +def get_user_rating_count(user): + user_id = getattr(user, "id", user) + return Rating.objects.filter(behavior__user_id=user_id).count() or 0 \ No newline at end of file diff --git a/backend/rating/views/penalty.py b/backend/rating/views/penalty.py new file mode 100644 index 00000000..53003b44 --- /dev/null +++ b/backend/rating/views/penalty.py @@ -0,0 +1,57 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.generics import ListAPIView, CreateAPIView, UpdateAPIView +from rest_framework.response import Response +from rating.models.penalty import Penalty +from rating.models.penalty_category import PenaltyCategory +from rating.serializers.penalty import CreatePenaltySerializer, PenaltyCategorySerializer, PenaltySerializer, UpdatePenaltyStatusSerializer +from user_auth.constants import EMPLOYER_ROLE +from rest_framework.response import Response +from rest_framework import status + +class ListPenaltyCategoriesView(ListAPIView): + """ + Devuelve todas las categorías de penalización con sus tipos. + """ + permission_classes = [IsAuthenticated] + required_groups = [EMPLOYER_ROLE] + serializer_class = PenaltyCategorySerializer + queryset = PenaltyCategory.objects.prefetch_related('types').all() + + +class CreatePenaltyView(CreateAPIView): + """ + Crea una nueva penalización para un usuario. + """ + permission_classes = [IsAuthenticated] + required_groups = [EMPLOYER_ROLE] + serializer_class = CreatePenaltySerializer + queryset = Penalty.objects.none() + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + penalty = serializer.save() + + # Usamos serializer de respuesta + response_serializer = PenaltySerializer(penalty) + return Response(response_serializer.data, status=201) + + +class UpdatePenaltyStatusView(UpdateAPIView): + permission_classes = [IsAuthenticated] + required_groups = [EMPLOYER_ROLE] + serializer_class = UpdatePenaltyStatusSerializer + queryset = Penalty.objects.all() + lookup_field = "id" + + def update(self, request, *args, **kwargs): + penalty = self.get_object() + serializer = self.get_serializer( + penalty, + data=request.data, + partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/backend/rating/views/rating.py b/backend/rating/views/rating.py new file mode 100644 index 00000000..20210db2 --- /dev/null +++ b/backend/rating/views/rating.py @@ -0,0 +1,362 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.generics import RetrieveAPIView, ListAPIView + +from config.pagination import CustomPagination +from notifications.constants import NotificationTypes +from notifications.services.send_notification import send_notification +from rating.models.users_connections import UserConnection +from user_auth.models.employee import EmployeeProfile +from user_auth.permissions import IsInGroup +from user_auth.constants import EMPLOYER_ROLE, EMPLOYEE_ROLE + +from rating.serializers.rating import ( + EmployeeRatingDetailSerializer, + EmployerRatingDetailSerializer, + ListRatingsEmployeeSerializer, + ListRatingsEmployerSerializer, + ViewRatingsSerializer, + ListEmployerEventsSerializer, + SingleEmployerRatingSerializer, + SingleRatingSerializer, +) +from rating.models import Behavior, Rating +from django.db import transaction +from user_auth.models import CustomUser +from eventos.models.event import Event + +from rating.errors.rating_menssage import ( + BODY_MUST_BE_ARRAY, + EVENT_NOT_FOUND, + RATINGS_SAVED, + SOME_RATINGS_NOT_SAVED, + RATING_ALREADY_EXISTS, +) + +from applications.models import Offer +from applications.constants import OfferStates +from applications.models.offer_state import OfferState + +import logging +logger = logging.getLogger(__name__) + + +class BulkCreateRatingView(APIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + + def post(self, request, *args, **kwargs): + data = request.data + if not isinstance(data, list): + return Response({"message": BODY_MUST_BE_ARRAY}, status=400) + validated_items, errors = self._validate_and_prepare_employee(data, request) + + if not validated_items: + return Response({"message": SOME_RATINGS_NOT_SAVED, "errors": errors}, status=status.HTTP_400_BAD_REQUEST) + + try: + self._create_ratings_from_employees(validated_items, request.user) + except Exception as exc: + return Response({"message": SOME_RATINGS_NOT_SAVED, "errors": [str(exc)]}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + if errors: + return Response({"message": SOME_RATINGS_NOT_SAVED, "errors": errors}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"message": RATINGS_SAVED}, status=status.HTTP_201_CREATED) + + def _validate_and_prepare_employee(self, data, request): + errors = [] + validated_items = [] + seen_pairs = set() + + for item in data: + serializer = SingleRatingSerializer(data=item, context={'request': request}) + if not serializer.is_valid(): + for val in serializer.errors.values(): + if isinstance(val, (list, tuple)): + for msg in val: + errors.append(str(msg)) + else: + errors.append(str(val)) + continue + + v = serializer.validated_data + key = (v['employee'], v['event']) + if key in seen_pairs: + errors.append(RATING_ALREADY_EXISTS) + else: + seen_pairs.add(key) + validated_items.append(v) + + if errors: + return [], errors + + return validated_items, [] + + def _create_ratings_from_employees(self, validated_items, rater): + rating_objs = [] + affected_behaviors = set() + + for v in validated_items: + employee_user = v.get('employee_user') + event_obj = v.get('event_obj') + link = v.get('link', False) # valor booleano del frontend + + if not employee_user or not event_obj: + continue + + # --- manejar vinculación --- + if link: + # crear la relación si no existe + UserConnection.objects.get_or_create( + employee=employee_user, + employer=rater + ) + else: + # eliminar la relación si existe + UserConnection.objects.filter( + employee=employee_user, + employer=rater + ).delete() + + # --- crear rating --- + behavior, _ = Behavior.objects.get_or_create(user=employee_user) + affected_behaviors.add(behavior) + rating_objs.append(Rating( + behavior=behavior, + rater=rater, + event=event_obj, + rating=v['rating'], + comments=v.get('comments', ""), + )) + + try: + send_notification( + user=employee_user, + title="¡Calificación recibida!", + message=f"{rater.employer_profile.company_name} te calificó por tu trabajo en '{event_obj.name}'.", + notification_type_name=NotificationTypes.JOBS.value, + data={ + "event_id": event_obj.id, + "rating_value": v['rating'], + "rater_name": rater.employer_profile.company_name + } + ) + except Exception as e: + logger.error("Error enviando notificación:", e) + + if rating_objs: + with transaction.atomic(): + Rating.objects.bulk_create(rating_objs) + for behavior in affected_behaviors: + behavior.update_average_rating() + +class ViewRatings(RetrieveAPIView): + serializer_class = ViewRatingsSerializer + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE, EMPLOYEE_ROLE] + lookup_field = 'user_id' + lookup_url_kwarg = 'user_id' + + def get_queryset(self): + return Behavior.objects.all() + + def get_object(self): + + user_id = self.kwargs.get(self.lookup_url_kwarg) + qs = Behavior.objects.filter(user_id=user_id) + obj = qs.first() + + return obj + +class ListEmployersEventsView(ListAPIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + serializer_class = ListEmployerEventsSerializer + + def get_queryset(self): + offer_completed_state = OfferState.objects.get(name=OfferStates.COMPLETED.value) + employee_profile = self.request.user.employee_profile + employee_user = self.request.user + + # Todos los Offers completados + qs = Offer.objects.filter( + employee=employee_profile, + state=offer_completed_state + ).select_related( + "selected_shift", + "selected_shift__vacancy", + "selected_shift__vacancy__event", + "selected_shift__vacancy__event__owner", + ) + + # IDs de eventos ya calificados por este empleado al empleador de ese evento + rated_event_ids = Rating.objects.filter( + rater=employee_user + ).values_list('event_id', flat=True) + + # Excluir Offers de eventos ya calificados + qs = qs.exclude(selected_shift__vacancy__event_id__in=rated_event_ids) + + return qs + + +class BulkCreateEmployerRatingView(APIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def post(self, request, *args, **kwargs): + data = request.data + if not isinstance(data, list): + return Response(BODY_MUST_BE_ARRAY, status=400) + + validated_items, errors = self._validate_and_prepare(data, request) + + if not validated_items: + return Response({"message": SOME_RATINGS_NOT_SAVED, "errors": errors}, status=status.HTTP_400_BAD_REQUEST) + + try: + self._create_ratings(validated_items, request.user) + except Exception as exc: + # If the DB operation fails we return a server error and ensure + # nothing was partially committed thanks to transaction.atomic() + return Response( + {"message": SOME_RATINGS_NOT_SAVED, "errors": [str(exc)]}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + if errors: + return Response({"message": SOME_RATINGS_NOT_SAVED, "errors": errors}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"message": RATINGS_SAVED}, status=status.HTTP_201_CREATED) + + def _validate_and_prepare(self, data, request): + """Validate batch items using the serializer and return validated_data list + and a flat list of error messages. + """ + errors = [] + validated_items = [] + seen_pairs = set() + + # Validate each item individually and collect errors + for item in data: + serializer = SingleEmployerRatingSerializer(data=item, context={'request': request}) + if not serializer.is_valid(): + for val in serializer.errors.values(): + if isinstance(val, (list, tuple)): + for msg in val: + errors.append(str(msg)) + else: + errors.append(str(val)) + continue + + v = serializer.validated_data + key = (v['employer'], v['event']) + if key in seen_pairs: + # duplicate within the same batch + errors.append(RATING_ALREADY_EXISTS) + else: + seen_pairs.add(key) + validated_items.append(v) + + # If any errors found, return empty validated_items so the view does not create anything + if errors: + return [], errors + + return validated_items, [] + + def _create_ratings(self, validated_items, rater): + """Bulk create Rating objects from validated_data and update behaviors. + """ + rating_objs = [] + affected_behaviors = set() + + for v in validated_items: + employer_user = v.get('employer_user') + event_obj = v.get('event_obj') + if not employer_user or not event_obj: + continue + + behavior, _ = Behavior.objects.get_or_create(user=employer_user) + affected_behaviors.add(behavior) + rating_objs.append(Rating( + behavior=behavior, + rater=rater, + event=event_obj, + rating=v['rating'], + comments=v.get('comments', ""), + )) + + if rating_objs: + # Ensure all DB changes happen atomically + with transaction.atomic(): + Rating.objects.bulk_create(rating_objs) + for behavior in affected_behaviors: + behavior.update_average_rating() + +class EmployeeRatingDetailView(ListAPIView): + serializer_class = EmployeeRatingDetailSerializer + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def get_queryset(self): + user_employee_id = self.request.user.id + return Rating.objects.filter( + behavior__user_id=user_employee_id + ).select_related('rater', 'behavior') + +class EmployerRatingDetailView(ListAPIView): + serializer_class = EmployerRatingDetailSerializer + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + pagination_class = CustomPagination + + def get_queryset(self): + employer_user = self.request.user + events_hosted = Event.objects.filter( + owner_id=employer_user.id + ).values_list("id", flat=True) + return Rating.objects.filter( + event_id__in=events_hosted, + rater__role="employee" + ).select_related( + "event", "rater", "behavior" + ).order_by("-date") + +class ListRatingsEmployeeView(ListAPIView): + serializer_class = ListRatingsEmployeeSerializer + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + pagination_class = CustomPagination + + def get_queryset(self): + employee_id = self.kwargs.get("employee_id") + + employee_user = EmployeeProfile.objects.get(id=employee_id).user + + return Rating.objects.filter( + behavior__user=employee_user + ).select_related( + "event", "rater", "behavior" + ).order_by("-date") + + +class ListRatingsEmployerView(ListAPIView): + serializer_class = ListRatingsEmployerSerializer + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + pagination_class = CustomPagination + + def get_queryset(self): + employer_id = self.kwargs.get("employer_id") + + employer_user = CustomUser.objects.get(id=employer_id) + + return Rating.objects.filter( + behavior__user=employer_user + ).select_related( + "event", "rater", "behavior" + ).order_by("-date") + diff --git a/backend/requirements.txt b/backend/requirements.txt index dc43cdd0..19785728 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/backend/user_auth/adapters.py b/backend/user_auth/adapters.py new file mode 100644 index 00000000..0e53e245 --- /dev/null +++ b/backend/user_auth/adapters.py @@ -0,0 +1,17 @@ +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter + +class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): + def populate_user(self, request, sociallogin, data): + """ + Sobrescribimos populate_user para evitar que allauth + intente pisar el campo 'phone' cuando viene de Google. + """ + + data.pop("phone", None) + data.pop("phone_number", None) + data.pop("phoneNumbers", None) + + user = super().populate_user(request, sociallogin, data) + + + return user diff --git a/backend/user_auth/constants.py b/backend/user_auth/constants.py index 7a3351e9..b030893a 100644 --- a/backend/user_auth/constants.py +++ b/backend/user_auth/constants.py @@ -1,2 +1,3 @@ EMPLOYEE_ROLE = 'employee' EMPLOYER_ROLE = 'employer' +LANGUAGES_PATH = 'user_auth/data/languages.json' diff --git a/backend/user_auth/data/languages.json b/backend/user_auth/data/languages.json new file mode 100644 index 00000000..e696352b --- /dev/null +++ b/backend/user_auth/data/languages.json @@ -0,0 +1,66 @@ +[ + "Español", + "Inglés", + "Francés", + "Alemán", + "Italiano", + "Portugués", + "Chino", + "Japonés", + "Ruso", + "Árabe", + "Coreano", + "Turco", + "Hindi", + "Sueco", + "Holandés", + "Polaco", + "Griego", + "Danés", + "Finlandés", + "Noruego", + "Hebreo", + "Catalán", + "Gallego", + "Euskera", + "Checo", + "Húngaro", + "Rumano", + "Búlgaro", + "Serbio", + "Croata", + "Esloveno", + "Eslovaco", + "Ucraniano", + "Lituano", + "Letón", + "Estonio", + "Tailandés", + "Vietnamita", + "Malayo", + "Indonesio", + "Filipino", + "Swahili", + "Afrikáans", + "Islandés", + "Macedonio", + "Georgiano", + "Armenio", + "Persa", + "Pashto", + "Urdu", + "Bengalí", + "Punjabi", + "Tamil", + "Telugu", + "Gujarati", + "Kannada", + "Maratí", + "Nepalí", + "Sinhala", + "Birmano", + "Jemer", + "Lao", + "Mongol", + "Tibetano" +] diff --git a/backend/user_auth/errors/language_errors.py b/backend/user_auth/errors/language_errors.py new file mode 100644 index 00000000..5982b4f4 --- /dev/null +++ b/backend/user_auth/errors/language_errors.py @@ -0,0 +1 @@ +PROFILE_NOT_FOUND = "Perfil de empleado no encontrado." \ No newline at end of file diff --git a/backend/user_auth/errors/user_errors_messages.py b/backend/user_auth/errors/user_errors_messages.py new file mode 100644 index 00000000..55910ce4 --- /dev/null +++ b/backend/user_auth/errors/user_errors_messages.py @@ -0,0 +1,4 @@ +EMAIL_REQUIRED = "El correo electrónico es obligatorio." +EMPLOYEE_PROFILE_NOT_FOUND = "Perfil de empleado no encontrado." +WORK_EXPERIENCE_NOT_FOUND = "Experiencia laboral no encontrada." +EDUCATION_NOT_FOUND = "Educación no encontrada." \ No newline at end of file diff --git a/backend/user_auth/migrations/0002_workexperience_educationcertification.py b/backend/user_auth/migrations/0002_workexperience_educationcertification.py new file mode 100644 index 00000000..026441ed --- /dev/null +++ b/backend/user_auth/migrations/0002_workexperience_educationcertification.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.23 on 2025-10-25 10:33 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('media_utils', '0002_initial'), + ('user_auth', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='WorkExperience', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ('work_type', models.TextField()), + ('company_or_event', models.CharField(max_length=100)), + ('start_date', models.DateField()), + ('end_date', models.DateField(blank=True, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_experiences', to='user_auth.employeeprofile')), + ('image', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='work_experience_image', to='media_utils.image')), + ], + ), + migrations.CreateModel( + name='EducationCertification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('institution', models.CharField(max_length=100)), + ('title', models.CharField(max_length=100)), + ('discipline', models.CharField(blank=True, max_length=100, null=True)), + ('start_date', models.DateField()), + ('end_date', models.DateField(blank=True, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='educations', to='user_auth.employeeprofile')), + ('image', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='education_image', to='media_utils.image')), + ], + ), + ] diff --git a/backend/user_auth/migrations/0003_alter_customuser_phone.py b/backend/user_auth/migrations/0003_alter_customuser_phone.py new file mode 100644 index 00000000..2cb6851f --- /dev/null +++ b/backend/user_auth/migrations/0003_alter_customuser_phone.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.23 on 2025-10-25 11:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user_auth", "0002_workexperience_educationcertification"), + ] + + operations = [ + migrations.AlterField( + model_name="customuser", + name="phone", + field=models.CharField(blank=True, max_length=20, null=True, unique=True), + ), + ] diff --git a/backend/user_auth/migrations/0004_alter_phoneverification_phone.py b/backend/user_auth/migrations/0004_alter_phoneverification_phone.py new file mode 100644 index 00000000..9e6ea1ec --- /dev/null +++ b/backend/user_auth/migrations/0004_alter_phoneverification_phone.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.23 on 2025-10-25 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("user_auth", "0003_alter_customuser_phone"), + ] + + operations = [ + migrations.AlterField( + model_name="phoneverification", + name="phone", + field=models.CharField(max_length=15), + ), + ] diff --git a/backend/user_auth/migrations/0005_employerprofile_description.py b/backend/user_auth/migrations/0005_employerprofile_description.py new file mode 100644 index 00000000..efeeb15a --- /dev/null +++ b/backend/user_auth/migrations/0005_employerprofile_description.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.23 on 2025-10-26 17:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_auth', '0004_alter_phoneverification_phone'), + ] + + operations = [ + migrations.AddField( + model_name='employerprofile', + name='description', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/backend/user_auth/migrations/0006_languagelevel_employeelanguage.py b/backend/user_auth/migrations/0006_languagelevel_employeelanguage.py new file mode 100644 index 00000000..fda381d6 --- /dev/null +++ b/backend/user_auth/migrations/0006_languagelevel_employeelanguage.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.23 on 2025-11-23 10:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("user_auth", "0005_employerprofile_description"), + ] + + operations = [ + migrations.CreateModel( + name="LanguageLevel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=50, unique=True)), + ], + ), + migrations.CreateModel( + name="EmployeeLanguage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("language", models.CharField(max_length=50)), + ("notes", models.TextField(blank=True, null=True)), + ( + "employee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="languages", + to="user_auth.employeeprofile", + ), + ), + ( + "level", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="employee_languages", + to="user_auth.languagelevel", + ), + ), + ], + ), + ] diff --git a/backend/user_auth/migrations/0007_populate_language_levels.py b/backend/user_auth/migrations/0007_populate_language_levels.py new file mode 100644 index 00000000..e77318cf --- /dev/null +++ b/backend/user_auth/migrations/0007_populate_language_levels.py @@ -0,0 +1,35 @@ +from django.db import migrations + +LANGUAGE_LEVELS = [ + { + "name": "Básico", + }, + { + "name": "Intermedio", + }, + { + "name": "Avanzado", + }, + { + "name": "Nativo", + }, +] + + +def populate_language_levels(apps, schema_editor): + LanguageLevel = apps.get_model('user_auth', 'LanguageLevel') + for level in LANGUAGE_LEVELS: + LanguageLevel.objects.get_or_create( + name=level["name"], + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_auth', '0006_languagelevel_employeelanguage'), + ] + + operations = [ + migrations.RunPython(populate_language_levels), + ] diff --git a/backend/user_auth/models/education_certification.py b/backend/user_auth/models/education_certification.py new file mode 100644 index 00000000..1c1b99c9 --- /dev/null +++ b/backend/user_auth/models/education_certification.py @@ -0,0 +1,14 @@ +from django.db import models + +from media_utils.models import Image +from user_auth.models.employee import EmployeeProfile + +class EducationCertification(models.Model): + employee = models.ForeignKey(EmployeeProfile, on_delete=models.CASCADE, related_name='educations') + institution = models.CharField(max_length=100) + title = models.CharField(max_length=100) + discipline = models.CharField(max_length=100, blank=True, null=True) + start_date = models.DateField(null=False, blank=False) + end_date = models.DateField(null=True, blank=True) + description = models.TextField(blank=True, null=True) + image = models.OneToOneField(Image, on_delete=models.SET_NULL, null=True, blank=True, related_name='education_image') diff --git a/backend/user_auth/models/employer.py b/backend/user_auth/models/employer.py index 28101d7a..e3b7e3aa 100644 --- a/backend/user_auth/models/employer.py +++ b/backend/user_auth/models/employer.py @@ -3,6 +3,8 @@ class EmployerProfile(models.Model): company_name = models.CharField(max_length=255) + description = models.CharField(max_length=255, null=True, blank=True) + user = models.OneToOneField(CustomUser, on_delete=models.CASCADE, related_name='employer_profile') diff --git a/backend/user_auth/models/language.py b/backend/user_auth/models/language.py new file mode 100644 index 00000000..90ec2130 --- /dev/null +++ b/backend/user_auth/models/language.py @@ -0,0 +1,30 @@ + +from django.db import models +from user_auth.models.employee import EmployeeProfile + + +class LanguageLevel(models.Model): + name = models.CharField(max_length=50, unique=True) + + def __str__(self): + return self.name + + +class EmployeeLanguage(models.Model): + employee = models.ForeignKey( + EmployeeProfile, + on_delete=models.CASCADE, + related_name='languages' + ) + language = models.CharField(max_length=50) + level = models.ForeignKey( + LanguageLevel, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='employee_languages' + ) + notes = models.TextField(blank=True, null=True) + + def __str__(self): + return f"{self.employee.user.email} - {self.language} ({self.level})" diff --git a/backend/user_auth/models/user.py b/backend/user_auth/models/user.py index a4b0d63b..9aa3d6be 100644 --- a/backend/user_auth/models/user.py +++ b/backend/user_auth/models/user.py @@ -38,7 +38,7 @@ def create_superuser(self, email, password=None, **extra_fields): class CustomUser(AbstractUser): email = models.EmailField(unique=True, null=False, blank=False) - phone = models.CharField(max_length=20, null=False, blank=False, unique=True) + phone = models.CharField(max_length=20, null=True, blank=True, unique=True) profile_image = models.OneToOneField( Image, @@ -78,4 +78,6 @@ def is_employee(self): USERNAME_FIELD = 'email' REQUIRED_FIELDS = [] - objects = CustomUserManager() \ No newline at end of file + objects = CustomUserManager() + + diff --git a/backend/user_auth/models/validation.py b/backend/user_auth/models/validation.py index 053e6099..d273699b 100644 --- a/backend/user_auth/models/validation.py +++ b/backend/user_auth/models/validation.py @@ -13,7 +13,7 @@ def is_valid(self): class PhoneVerification(models.Model): - phone = models.CharField(max_length=15, unique=True) + phone = models.CharField(max_length=15) is_verified = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) verified_at = models.DateTimeField(null=True, blank=True) diff --git a/backend/user_auth/models/work_experience.py b/backend/user_auth/models/work_experience.py new file mode 100644 index 00000000..1e656021 --- /dev/null +++ b/backend/user_auth/models/work_experience.py @@ -0,0 +1,14 @@ +from django.db import models + +from media_utils.models import Image +from user_auth.models.employee import EmployeeProfile + +class WorkExperience(models.Model): + employee = models.ForeignKey(EmployeeProfile, on_delete=models.CASCADE, related_name='work_experiences') + title = models.CharField(max_length=100) + work_type = models.TextField() + company_or_event = models.CharField(max_length=100) + start_date = models.DateField(null=False, blank=False) + end_date = models.DateField(null=True, blank=True) + description = models.TextField(blank=True, null=True) + image = models.OneToOneField(Image, on_delete=models.SET_NULL, null=True, blank=True, related_name='work_experience_image') diff --git a/backend/user_auth/serializers/auth.py b/backend/user_auth/serializers/auth.py index b889a7c4..fb893374 100644 --- a/backend/user_auth/serializers/auth.py +++ b/backend/user_auth/serializers/auth.py @@ -30,4 +30,5 @@ def get_token(cls, user): token = super().get_token(user) token['email'] = user.email token['role'] = user.role + token['is_superuser'] = user.is_superuser return token diff --git a/backend/user_auth/serializers/employee.py b/backend/user_auth/serializers/employee.py index 043d4fe6..a7c30a75 100644 --- a/backend/user_auth/serializers/employee.py +++ b/backend/user_auth/serializers/employee.py @@ -2,6 +2,10 @@ from rest_framework import serializers from applications.errors.offer_messages import INVALID_RANGE_DATE, INVALID_RANGE_TIME, MISSING_PROVINCE from eventos.formatters.date_time import CustomDateField +from rating.utils import get_user_average_rating, get_user_rating_count +from user_auth.models.education_certification import EducationCertification +from user_auth.models.work_experience import WorkExperience +from user_auth.serializers.language import EmployeeLanguageSerializer from vacancies.formatters.date_time import CustomTimeField from vacancies.models.job_types import JobType from vacancies.serializers.job_types import ListJobTypesSerializer @@ -86,7 +90,7 @@ class CompleteEmployeeSocialSerializer(serializers.Serializer): phone = serializers.CharField(max_length=20) dni = serializers.CharField(max_length=20) address = serializers.CharField(max_length=255, required=False, allow_blank=True) - birth_date = serializers.DateField(required=False) + birth_date = CustomDateField(required=False) latitude = serializers.FloatField(required=False) longitude = serializers.FloatField(required=False) @@ -197,38 +201,114 @@ def to_representation(self, instance): return rep - -class EmployeeForOfferSearchSerializer(serializers.ModelSerializer): - profile_image = serializers.SerializerMethodField() - name = serializers.SerializerMethodField() - age = serializers.SerializerMethodField() - approximate_location = serializers.SerializerMethodField() +class EmployeeProfileDescriptionSerializer(serializers.ModelSerializer): + profile_image_url = serializers.URLField(required=False, allow_null=True) + profile_image_id = serializers.CharField(required=False, allow_null=True) + description = serializers.CharField(required=False, allow_blank=True, allow_null=True) class Meta: model = EmployeeProfile - fields = ["profile_image", "name", "description", "age", "approximate_location"] + fields = ['description', 'profile_image_url', 'profile_image_id'] - def get_profile_image(self, obj): - return obj.user.profile_image.url if obj.user.profile_image else None + def validate(self, attrs): + url, img_id = attrs.get('profile_image_url'), attrs.get('profile_image_id') + if (url and not img_id) or (img_id and not url): + raise serializers.ValidationError( + "Both 'profile_image_url' and 'profile_image_id' must be provided together." + ) + return attrs - def get_name(self, obj): - return f"{obj.user.first_name} {obj.user.last_name}" + def update(self, instance, validated_data): + user = instance.user - def get_age(self, obj): - return calculate_age(obj.birth_date) + if 'description' in validated_data: + instance.description = validated_data['description'] - def get_approximate_location(self, obj): - return get_city_locality(obj.address) + url, img_id = validated_data.get('profile_image_url'), validated_data.get('profile_image_id') + if url and img_id: + image_obj, _ = Image.objects.update_or_create( + public_id=img_id, + defaults={'url': url, 'type': ImageType.PROFILE, 'uploaded_by': user} + ) + user.profile_image = image_obj + user.save() + + instance.save() + return instance + + +class EmployeeWorkExperienceSerializer(serializers.ModelSerializer): + start_date = CustomDateField() + end_date = CustomDateField(required=False, allow_null=True) + image_url = serializers.URLField(required=False, allow_null=True) + image_id = serializers.CharField(required=False, allow_null=True) + + class Meta: + model = WorkExperience + fields = ['title', 'work_type', 'company_or_event', 'start_date', 'end_date', 'description', 'image_url', 'image_id'] + + def validate(self, attrs): + url, img_id = attrs.get('image_url'), attrs.get('image_id') + if (url and not img_id) or (img_id and not url): + raise serializers.ValidationError("Both 'image_url' and 'image_id' must be provided together.") + return attrs + + def create(self, validated_data): + user = self.context['request'].user + employee = user.employee_profile + + url, img_id = validated_data.pop('image_url', None), validated_data.pop('image_id', None) + if url and img_id: + image_obj, _ = Image.objects.update_or_create( + public_id=img_id, + defaults={'url': url, 'type': ImageType.OTHER, 'uploaded_by': user} + ) + validated_data['image'] = image_obj + + return WorkExperience.objects.create(employee=employee, **validated_data) + +class EmployeeEducationSerializer(serializers.ModelSerializer): + start_date = CustomDateField() + end_date = CustomDateField(required=False, allow_null=True) + image_url = serializers.URLField(required=False, allow_null=True) + image_id = serializers.CharField(required=False, allow_null=True) + + class Meta: + model = EducationCertification + fields = ['institution', 'title', 'discipline', 'start_date', 'end_date', 'description', 'image_url', 'image_id'] + + def validate(self, attrs): + url, img_id = attrs.get('image_url'), attrs.get('image_id') + if (url and not img_id) or (img_id and not url): + raise serializers.ValidationError("Both 'image_url' and 'image_id' must be provided together.") + return attrs + + def create(self, validated_data): + user = self.context['request'].user + employee = user.employee_profile + + url, img_id = validated_data.pop('image_url', None), validated_data.pop('image_id', None) + if url and img_id: + image_obj, _ = Image.objects.update_or_create( + public_id=img_id, + defaults={'url': url, 'type': ImageType.OTHER, 'uploaded_by': user} + ) + validated_data['image'] = image_obj + + return EducationCertification.objects.create(employee=employee, **validated_data) + class EmployeeProfileSearchSerializer(serializers.ModelSerializer): employee_id = serializers.IntegerField(source="id") profile_image = serializers.SerializerMethodField() name = serializers.SerializerMethodField() approximate_location = serializers.SerializerMethodField() + average_rating = serializers.SerializerMethodField() + rating_count = serializers.SerializerMethodField() class Meta: model = EmployeeProfile - fields = ["employee_id", "profile_image", "name", "approximate_location"] + fields = ["employee_id", "profile_image", "name", "approximate_location", "average_rating", "rating_count"] def get_profile_image(self, obj): return obj.user.profile_image.url if obj.user.profile_image else None @@ -238,6 +318,14 @@ def get_name(self, obj): def get_approximate_location(self, obj): return get_city_locality(obj.address) + + def get_average_rating(self, obj): + average = get_user_average_rating(obj.user) + return average + + def get_rating_count(self, obj): + count = get_user_rating_count(obj.user) + return count class EmployeeSearchFilterSerializer(serializers.Serializer): province = serializers.CharField(required=False, allow_blank=True) @@ -263,4 +351,253 @@ def validate(self, data): if data["start_time"] > data["end_time"]: raise serializers.ValidationError(INVALID_RANGE_TIME) - return data \ No newline at end of file + return data + +class EmployeeInterestsSerializer(serializers.ModelSerializer): + job_types = serializers.PrimaryKeyRelatedField( + queryset=JobType.objects.all(), + many=True, + required=True + ) + + class Meta: + model = EmployeeProfile + fields = ['job_types'] + + def validate_job_types(self, value): + if len(value) > 3: + raise serializers.ValidationError("Solo se pueden seleccionar hasta 3 intereses.") + return value + + def update(self, instance, validated_data): + if 'job_types' in validated_data: + instance.job_types.set(validated_data['job_types']) + instance.save() + return instance + +class ViewEmployeeWorkExperienceSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() + image_id = serializers.SerializerMethodField() + + class Meta: + model = WorkExperience + fields = ['id', 'title', 'company_or_event', 'start_date', 'work_type','end_date', 'description', 'image_url', 'image_id'] + + def get_image_url(self, obj): + return obj.image.url if obj.image else None + + def get_image_id(self, obj): + return obj.image.public_id if obj.image else None + +class ViewEmployeeEducationSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() + image_id = serializers.SerializerMethodField() + + class Meta: + model = EducationCertification + fields = ['id', 'institution', 'title', 'discipline', 'start_date', 'end_date', 'description', 'image_url', 'image_id'] + + def get_image_url(self, obj): + return obj.image.url if obj.image else None + + def get_image_id(self, obj): + return obj.image.public_id if obj.image else None + +class ViewEmployeeInterestsSerializer(serializers.ModelSerializer): + job_types = ListJobTypesSerializer(many=True) + + class Meta: + model = EmployeeProfile + fields = ['job_types'] + + +class ViewEmployeeProfileDescriptionSerializer(serializers.ModelSerializer): + profile_image_url = serializers.SerializerMethodField() + profile_image_id = serializers.SerializerMethodField() + description = serializers.CharField() + first_name = serializers.CharField(source='user.first_name') + last_name = serializers.CharField(source='user.last_name') + birth_date = CustomDateField() + + class Meta: + model = EmployeeProfile + fields = ['description', 'profile_image_id', 'profile_image_url', 'first_name', 'last_name', 'dni', 'birth_date', 'address'] + + def get_profile_image_url(self, obj): + return obj.user.profile_image.url if obj.user.profile_image else None + + def get_profile_image_id(self, obj): + return obj.user.profile_image.public_id if obj.user.profile_image else None + +class EmployeeForOfferSearchSerializer(serializers.ModelSerializer): + profile_image = serializers.SerializerMethodField() + name = serializers.SerializerMethodField() + age = serializers.SerializerMethodField() + approximate_location = serializers.SerializerMethodField() + average_rating = serializers.SerializerMethodField() + rating_count = serializers.SerializerMethodField() + work_experiences = ViewEmployeeWorkExperienceSerializer(many=True) + educations = ViewEmployeeEducationSerializer(many=True) + languages = EmployeeLanguageSerializer(many=True) + + class Meta: + model = EmployeeProfile + fields = ["id","profile_image", "name", "description", "age", "approximate_location", "average_rating", "rating_count", "work_experiences", "educations", "languages"] + + def get_profile_image(self, obj): + return obj.user.profile_image.url if obj.user.profile_image else None + + def get_name(self, obj): + return f"{obj.user.first_name} {obj.user.last_name}" + + def get_age(self, obj): + return calculate_age(obj.birth_date) + + def get_approximate_location(self, obj): + return get_city_locality(obj.address) + + def get_average_rating(self, obj): + return get_user_average_rating(obj.user) + + def get_rating_count(self, obj): + return get_user_rating_count(obj.user) + +class UpdateEmployeeProfileDescriptionSerializer(serializers.Serializer): + description = serializers.CharField(required=True) + birth_date = CustomDateField(required=True) + address = serializers.CharField(required=True) + + first_name = serializers.CharField(required=True) + last_name = serializers.CharField(required=True) + + profile_image_url = serializers.CharField(required=True, allow_null=True) + profile_image_id = serializers.CharField(required=True, allow_null=True) + + def validate(self, attrs): + url = attrs.get("profile_image_url") + img_id = attrs.get("profile_image_id") + + if (url is None) ^ (img_id is None): + # Si envía uno pero no el otro → error + raise serializers.ValidationError( + "Debe enviar ambos campos de imagen o ambos en null." + ) + + return attrs + + def update(self, instance, validated_data): + user = instance.user + + image_url = validated_data.pop('profile_image_url') + image_id = validated_data.pop('profile_image_id') + + if image_url is None and image_id is None: + user.profile_image = None + else: + image_obj, _ = Image.objects.update_or_create( + public_id=image_id, + defaults={ + 'url': image_url, + 'type': ImageType.PROFILE, + 'uploaded_by': self.context['user'] + } + ) + user.profile_image = image_obj + + user.first_name = validated_data.pop('first_name') + user.last_name = validated_data.pop('last_name') + user.save() + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.save() + return instance + +class UpdateEmployeeEducationSerializer(serializers.ModelSerializer): + start_date = CustomDateField(required=True) + end_date = CustomDateField(required=True, allow_null=True) + + image_url = serializers.URLField(required=True, allow_null=True) + image_id = serializers.CharField(required=True, allow_null=True) + + class Meta: + model = EducationCertification + fields = [ + 'institution', 'title', 'discipline', + 'start_date', 'end_date', 'description', + 'image_url', 'image_id' + ] + extra_kwargs = {field: {"required": True} for field in fields} + + def validate(self, attrs): + url, img_id = attrs.get('image_url'), attrs.get('image_id') + if (url and not img_id) or (img_id and not url): + raise serializers.ValidationError("Both 'image_url' and 'image_id' must be provided together.") + return attrs + + def update(self, instance, validated_data): + user = self.context['request'].user + + url = validated_data.pop('image_url', None) + img_id = validated_data.pop('image_id', None) + + if url and img_id: + image_obj, _ = Image.objects.update_or_create( + public_id=img_id, + defaults={'url': url, 'type': ImageType.OTHER, 'uploaded_by': user} + ) + instance.image = image_obj + + elif url is None and img_id is None: + instance.image = None + + for attr, val in validated_data.items(): + setattr(instance, attr, val) + + instance.save() + return instance + +class UpdateEmployeeWorkExperienceSerializer(serializers.ModelSerializer): + start_date = CustomDateField(required=True) + end_date = CustomDateField(required=True, allow_null=True) + + image_url = serializers.URLField(required=True, allow_null=True) + image_id = serializers.CharField(required=True, allow_null=True) + + class Meta: + model = WorkExperience + fields = [ + 'title', 'work_type', 'company_or_event', + 'start_date', 'end_date', 'description', + 'image_url', 'image_id' + ] + extra_kwargs = {field: {"required": True} for field in fields} + + def validate(self, attrs): + url, img_id = attrs.get('image_url'), attrs.get('image_id') + if (url and not img_id) or (img_id and not url): + raise serializers.ValidationError("Both 'image_url' and 'image_id' must be provided together.") + return attrs + + def update(self, instance, validated_data): + user = self.context['request'].user + + url = validated_data.pop('image_url', None) + img_id = validated_data.pop('image_id', None) + + if url and img_id: + image_obj, _ = Image.objects.update_or_create( + public_id=img_id, + defaults={'url': url, 'type': ImageType.OTHER, 'uploaded_by': user} + ) + instance.image = image_obj + + elif url is None and img_id is None: + instance.image = None + + for attr, val in validated_data.items(): + setattr(instance, attr, val) + + instance.save() + return instance \ No newline at end of file diff --git a/backend/user_auth/serializers/employer.py b/backend/user_auth/serializers/employer.py index 82c35f68..55e47b69 100644 --- a/backend/user_auth/serializers/employer.py +++ b/backend/user_auth/serializers/employer.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from media_utils.models import Image, ImageType from user_auth.utils import get_username_from_email from user_auth.constants import EMPLOYER_ROLE from user_auth.models.user import CustomUser @@ -85,4 +86,111 @@ def save(self): employer_group, created = Group.objects.get_or_create(name='employer') user.groups.add(employer_group) - return user \ No newline at end of file + return user + + +class EmployerProfileDescriptionSerializer(serializers.ModelSerializer): + profile_image_url = serializers.URLField(required=False, allow_null=True) + profile_image_id = serializers.CharField(required=False, allow_null=True) + description = serializers.CharField(required=False, allow_blank=True, allow_null=True) + + class Meta: + model = EmployerProfile + fields = ['description', 'profile_image_url', 'profile_image_id'] + + def validate(self, attrs): + url, img_id = attrs.get('profile_image_url'), attrs.get('profile_image_id') + if (url and not img_id) or (img_id and not url): + raise serializers.ValidationError( + "Both 'profile_image_url' and 'profile_image_id' must be provided together." + ) + return attrs + + def update(self, instance, validated_data): + user = instance.user + + if 'description' in validated_data: + instance.description = validated_data['description'] + + url, img_id = validated_data.get('profile_image_url'), validated_data.get('profile_image_id') + if url and img_id: + image_obj, _ = Image.objects.update_or_create( + public_id=img_id, + defaults={'url': url, 'type': ImageType.PROFILE, 'uploaded_by': user} + ) + user.profile_image = image_obj + user.save() + + instance.save() + return instance + + +class ViewEmployerProfileDescriptionSerializer(serializers.ModelSerializer): + profile_image_url = serializers.SerializerMethodField() + profile_image_id = serializers.SerializerMethodField() + + class Meta: + model = EmployerProfile + fields = ['id', 'company_name', 'description', 'profile_image_url', 'profile_image_id'] + + def get_profile_image_url(self, obj): + if obj.user.profile_image: + return obj.user.profile_image.url + return None + + def get_profile_image_id(self, obj): + if obj.user.profile_image: + return obj.user.profile_image.public_id + return None + + +class UpdateEmployerProfileSerializer(serializers.ModelSerializer): + profile_image_url = serializers.CharField(allow_null=True, required=False) + profile_image_id = serializers.CharField(allow_null=True, required=False) + + class Meta: + model = EmployerProfile + fields = ['company_name', 'description', 'profile_image_url', 'profile_image_id'] + + def validate(self, data): + image_url = data.get('profile_image_url') + image_id = data.get('profile_image_id') + + # Si el front manda cualquier cosa, debe mandar ambos + if (image_url and not image_id) or (image_id and not image_url): + raise serializers.ValidationError("Both profile_image_url and profile_image_id are required.") + + return data + + def update(self, instance, validated_data): + user = self.context['user'] + + # Obtener campos de imagen + image_url = validated_data.pop('profile_image_url', None) + image_id = validated_data.pop('profile_image_id', None) + + # Si se envía imagen nueva + if image_url and image_id: + image_obj, _ = Image.objects.update_or_create( + public_id=image_id, + defaults={ + 'url': image_url, + 'type': ImageType.PROFILE, + 'uploaded_by': user, + } + ) + user.profile_image = image_obj + user.save() + + # Si envían ambos como null → borrar imagen + elif image_url is None and image_id is None: + user.profile_image = None + user.save() + + # Actualizar el employer profile + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.save() + + return instance \ No newline at end of file diff --git a/backend/user_auth/serializers/language.py b/backend/user_auth/serializers/language.py new file mode 100644 index 00000000..f7cb7413 --- /dev/null +++ b/backend/user_auth/serializers/language.py @@ -0,0 +1,26 @@ +from rest_framework import serializers +from user_auth.models.language import EmployeeLanguage, LanguageLevel + + +class LanguageLevelSerializer(serializers.ModelSerializer): + class Meta: + model = LanguageLevel + fields = ['id', 'name'] + + +class EmployeeLanguageSerializer(serializers.ModelSerializer): + level = LanguageLevelSerializer() + + class Meta: + model = EmployeeLanguage + fields = ['id', 'language', 'level', 'notes'] + + +class EmployeeLanguageCreateSerializer(serializers.ModelSerializer): + class Meta: + model = EmployeeLanguage + fields = ['language', 'level', 'notes', 'employee'] + extra_kwargs = { + 'employee': {'read_only': True} + } + diff --git a/backend/user_auth/serializers/user.py b/backend/user_auth/serializers/user.py new file mode 100644 index 00000000..78ff4dd0 --- /dev/null +++ b/backend/user_auth/serializers/user.py @@ -0,0 +1,46 @@ +from rest_framework import serializers +from rating.utils import get_user_average_rating, get_user_rating_count +from user_auth.models.user import CustomUser + + +class UserPublicProfileSerializer(serializers.ModelSerializer): + user_name = serializers.SerializerMethodField() + description = serializers.SerializerMethodField() + rating = serializers.SerializerMethodField() + rating_count = serializers.SerializerMethodField() + image_url = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + fields = ['user_name', 'description', 'rating', 'rating_count', 'image_url'] + + def get_user_name(self, obj): + if hasattr(obj, "employee_profile"): + return f"{obj.first_name} {obj.last_name}".strip() + elif hasattr(obj, "employer_profile"): + return obj.employer_profile.company_name + return obj.email + + def get_description(self, obj): + if hasattr(obj, "employee_profile"): + return obj.employee_profile.description + elif hasattr(obj, "employer_profile"): + return obj.employer_profile.description + return None + + def get_rating(self, obj): + return get_user_average_rating(obj) + + def get_rating_count(self, obj): + return get_user_rating_count(obj) + + def get_image_url(self, obj): + return obj.profile_image.url if obj.profile_image else None + +class ViewMailAndPhoneSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + phone = serializers.CharField(allow_null=True) + + class Meta: + model = CustomUser + fields = ['email', 'phone'] \ No newline at end of file diff --git a/backend/user_auth/templates/emails/Jex-Logo-Violeta.png b/backend/user_auth/templates/emails/Jex-Logo-Violeta.png new file mode 100644 index 00000000..84ecae88 Binary files /dev/null and b/backend/user_auth/templates/emails/Jex-Logo-Violeta.png differ diff --git a/frontend/assets/images/jex/Jex-Olvidadizo.png b/backend/user_auth/templates/emails/Jex-Olvidadizo.png similarity index 100% rename from frontend/assets/images/jex/Jex-Olvidadizo.png rename to backend/user_auth/templates/emails/Jex-Olvidadizo.png diff --git a/backend/user_auth/templates/emails/password_reset.html b/backend/user_auth/templates/emails/password_reset.html new file mode 100644 index 00000000..42621c83 --- /dev/null +++ b/backend/user_auth/templates/emails/password_reset.html @@ -0,0 +1,85 @@ + + + + + + Restablecer contraseña - JEX + + + +
+ +

Restablecé tu contraseña

+

Hola {{ username }},

+

Recibimos una solicitud para restablecer tu contraseña.

+

Si no fuiste vos quien la solicitó, podés ignorar este mensaje.

+

Si fuiste vos, usá este código para continuar:

+

{{ otp }}

+

Este código es válido por {{ valid_minutes }} minutos.

+ + +
+ + diff --git a/backend/user_auth/urls.py b/backend/user_auth/urls.py index 080a4926..cd423dee 100644 --- a/backend/user_auth/urls.py +++ b/backend/user_auth/urls.py @@ -1,11 +1,14 @@ from django.urls import path +from user_auth.views.admin import AdminInfoView from user_auth.views.auth import EmailTokenObtainPairView, CustomGoogleLoginView, LogoutView from rest_framework_simplejwt.views import TokenRefreshView -from user_auth.views.employee import CompleteEmployeeSocialView, EmployeeAdditionalInfoView, EmployeeRegisterView -from user_auth.views.employer import CompleteEmployerSocialView, EmployerRegisterView +from user_auth.views.employee import CompleteEmployeeSocialView, DeleteEmployeeEducationView, DeleteEmployeeWorkExperienceView, EmployeeEducationView, EmployeeInterestsView, EmployeeProfileDescriptionView, EmployeeRegisterView, EmployeeValidateMailView, EmployeeWorkExperienceView, UpdateEmployeeEducationView, UpdateEmployeeProfileDescriptionView, UpdateEmployeeWorkExperienceView, ViewEmployeeEducation, ViewEmployeeInterests, ViewEmployeeProfileDescription, ViewEmployeeWorkExperience +from user_auth.views.employer import CompleteEmployerSocialView, EmployerProfileDescriptionView, EmployerRegisterView, UpdateEmployerProfileDescriptionView, ViewEmployerProfileDescription +from user_auth.views.language import EmployeeLanguagesBulkUpdateView, EmployeeLanguagesView, LanguageLevelsView, LanguagesListView from user_auth.views.password_reset import PasswordResetCompleteView, PasswordResetRequestView, PasswordResetVerifyView from user_auth.views.phone_verification import SendPhoneVerificationCodeView, VerifyPhoneCodeView +from user_auth.views.user import UserProfileView, ViewMailAndPhone urlpatterns = [ path('register/employer/', EmployerRegisterView.as_view(), name='register-employer'), @@ -21,5 +24,28 @@ path('password-reset-complete/', PasswordResetCompleteView.as_view(), name='password_reset_complete'), path('verify/send-code/', SendPhoneVerificationCodeView.as_view(), name='send-phone-code'), path('verify/check-code/', VerifyPhoneCodeView.as_view(), name='verify-phone-code'), - path('employee/additional-info/', EmployeeAdditionalInfoView.as_view(), name='employee-additional-info'), + path('employee/profile-description/', EmployeeProfileDescriptionView.as_view(), name='employee-profile-description'), + path('employee/work-experience/', EmployeeWorkExperienceView.as_view(), name='employee-work-experience'), + path('employee/education/', EmployeeEducationView.as_view(), name='employee-education'), + path('employee/interests/', EmployeeInterestsView.as_view(), name='employee-interests'), + path('employer/profile-description/', EmployerProfileDescriptionView.as_view(), name='employer-profile-description'), + path('validate-mail/', EmployeeValidateMailView.as_view(), name='employee-validate-mail'), + path('user/profile/', UserProfileView.as_view(), name='user-profile'), + path('employee/view-work-experience/', ViewEmployeeWorkExperience.as_view(), name='view-employee-work-experience'), + path('employee/view-education/', ViewEmployeeEducation.as_view(), name='view-employee-education'), + path('employee/view-interests/', ViewEmployeeInterests.as_view(), name='view-employee-interests'), + path('employee/view-profile-description/', ViewEmployeeProfileDescription.as_view(), name='view-employee-profile-description'), + path('employee/languages/', EmployeeLanguagesView.as_view(), name='employee-languages'), + path('employer/view-profile-description/', ViewEmployerProfileDescription.as_view(), name='view-employer-profile-description'), + path('language-levels/', LanguageLevelsView.as_view(), name='language-levels'), + path('languages/', LanguagesListView.as_view(), name='languages-list'), + path('employee/language/', EmployeeLanguagesBulkUpdateView.as_view(), name='employee-language-detail'), + path('view-mail-and-phone/', ViewMailAndPhone.as_view(), name='view-mail-and-phone'), + path('admin/info', AdminInfoView.as_view(), name='admin-info'), + path('employer/update-profile-description/', UpdateEmployerProfileDescriptionView.as_view(), name='update-employer-profile-description'), + path('employee/update-profile-description/', UpdateEmployeeProfileDescriptionView.as_view(), name='update-employee-profile-description'), + path('employee/update-education//', UpdateEmployeeEducationView.as_view(), name='update-employee-education'), + path('employee/update-work-experience//', UpdateEmployeeWorkExperienceView.as_view(), name='update-employee-work-experience'), + path('employee/delete-education//', DeleteEmployeeEducationView.as_view(), name='delete-employee-education'), + path('employee/delete-work-experience//', DeleteEmployeeWorkExperienceView.as_view(), name='delete-employee-work-experience') ] \ No newline at end of file diff --git a/backend/user_auth/utils.py b/backend/user_auth/utils.py index 227472ca..eb2683d4 100644 --- a/backend/user_auth/utils.py +++ b/backend/user_auth/utils.py @@ -1,5 +1,7 @@ from datetime import date, timezone +import json import random +from user_auth.constants import LANGUAGES_PATH def generate_otp(): return f"{random.randint(100000, 999999)}" @@ -34,8 +36,11 @@ def get_city_locality(address): if not parts: return None - if len(parts) >= 3: - barrio = parts[-3] - provincia = parts[-2] - return f"{barrio}, {provincia}" + if len(parts) >= 2: + return f"{parts[-2]}, {parts[-1]}" return parts[0] + + +def load_languages(): + with open(LANGUAGES_PATH, encoding='utf-8') as f: + return json.load(f) \ No newline at end of file diff --git a/backend/user_auth/views/admin.py b/backend/user_auth/views/admin.py new file mode 100644 index 00000000..c2c1b96f --- /dev/null +++ b/backend/user_auth/views/admin.py @@ -0,0 +1,39 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rating.models import Penalty +from rating.models.penalty_story_state import PenaltyStateHistory +from rating.serializers.penalty import AdminPenaltySerializer +from user_auth.permissions import IsInGroup +from rest_framework.permissions import IsAuthenticated +from django.db.models import Prefetch + + + +class AdminInfoView(APIView): + permission_classes = [IsAuthenticated, IsInGroup] + required_groups = ["Admin"] + + def get(self, request): + + state_history_qs = ( + PenaltyStateHistory.objects + .select_related("state", "changed_by") + .order_by("-changed_at") + ) + + penalties = ( + Penalty.objects + .select_related( + "behavior__user", + "punisher", + "event", + "penalty_state", + "penalty_type", + ) + .prefetch_related( + Prefetch("state_history", queryset=state_history_qs) + ) + ) + + serializer = AdminPenaltySerializer(penalties, many=True) + return Response(serializer.data) diff --git a/backend/user_auth/views/employee.py b/backend/user_auth/views/employee.py index c1cc0a4c..c8c42646 100644 --- a/backend/user_auth/views/employee.py +++ b/backend/user_auth/views/employee.py @@ -1,11 +1,17 @@ from rest_framework import status, permissions from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import NotFound, ValidationError from user_auth.constants import EMPLOYEE_ROLE +from user_auth.errors.user_errors_messages import EDUCATION_NOT_FOUND, EMAIL_REQUIRED, EMPLOYEE_PROFILE_NOT_FOUND, WORK_EXPERIENCE_NOT_FOUND +from user_auth.models.education_certification import EducationCertification from user_auth.models.employee import EmployeeProfile +from user_auth.models.work_experience import WorkExperience from user_auth.permissions import IsInGroup -from user_auth.serializers.employee import CompleteEmployeeSocialSerializer, EmployeeAdditionalInfoSerializer, EmployeeRegisterSerializer +from user_auth.serializers.employee import CompleteEmployeeSocialSerializer, EmployeeAdditionalInfoSerializer, EmployeeEducationSerializer, EmployeeInterestsSerializer, EmployeeProfileDescriptionSerializer, EmployeeRegisterSerializer, EmployeeWorkExperienceSerializer, UpdateEmployeeEducationSerializer, UpdateEmployeeProfileDescriptionSerializer, UpdateEmployeeWorkExperienceSerializer, ViewEmployeeEducationSerializer, ViewEmployeeInterestsSerializer, ViewEmployeeProfileDescriptionSerializer, ViewEmployeeWorkExperienceSerializer +from user_auth.models.user import CustomUser +from rest_framework import serializers +from django.db import transaction class EmployeeRegisterView(APIView): permission_classes = [permissions.AllowAny] @@ -50,4 +56,238 @@ def put(self, request): except Exception as e: return Response({"detail": f"Unexpected error: {str(e)}"}, status=500) - \ No newline at end of file + +class EmployeeProfileDescriptionView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def put(self, request): + + try: + profile = request.user.employee_profile + except EmployeeProfile.DoesNotExist: + return Response({"detail": "Perfil de empleado no encontrado."}, status=404) + + + serializer = EmployeeProfileDescriptionSerializer(profile, data=request.data, partial=True) + try: + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + except ValidationError as e: + return Response({"errors": e.detail}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({"detail": f"Unexpected error: {str(e)}"}, status=500) + +class EmployeeWorkExperienceView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def post(self, request): + data = request.data + saved_items = [] + + with transaction.atomic(): + for item in data: + serializer = EmployeeWorkExperienceSerializer(data=item, context={'request': request}) + serializer.is_valid(raise_exception=True) + saved_items.append(serializer.save()) + + return Response(status=status.HTTP_201_CREATED) + +class EmployeeEducationView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def post(self, request): + data = request.data + saved_items = [] + + with transaction.atomic(): + for item in data: + serializer = EmployeeEducationSerializer(data=item, context={'request': request}) + serializer.is_valid(raise_exception=True) + saved_items.append(serializer.save()) + + return Response(status=status.HTTP_201_CREATED) + +class EmployeeInterestsView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def put(self, request): + try: + profile = request.user.employee_profile + except EmployeeProfile.DoesNotExist: + return Response({"detail": "Perfil de empleado no encontrado."}, status=404) + + serializer = EmployeeInterestsSerializer(profile, data=request.data, partial=True) + + try: + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + except ValidationError as e: + return Response({"errors": e.detail}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({"detail": f"Unexpected error: {str(e)}"}, status=500) + +class EmployeeValidateMailView(APIView): + + def post(self, request): + email = request.data.get("email") + + if not email: + raise serializers.ValidationError(EMAIL_REQUIRED) + + exists = CustomUser.objects.filter(email__iexact=email).exists() + + return Response({"message": exists}, status=status.HTTP_200_OK) + +class ViewEmployeeWorkExperience(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def get(self, request): + try: + profile = request.user.employee_profile + except EmployeeProfile.DoesNotExist: + return Response(EMPLOYEE_PROFILE_NOT_FOUND, status=status.HTTP_404_NOT_FOUND) + + serializer = ViewEmployeeWorkExperienceSerializer(profile.work_experiences.all(), many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + +class ViewEmployeeEducation(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def get(self, request): + try: + profile = request.user.employee_profile + except EmployeeProfile.DoesNotExist: + return Response(EMPLOYEE_PROFILE_NOT_FOUND, status=status.HTTP_404_NOT_FOUND) + + serializer = ViewEmployeeEducationSerializer(profile.educations.all(), many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + +class ViewEmployeeInterests(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def get(self, request): + try: + profile = request.user.employee_profile + except EmployeeProfile.DoesNotExist: + return Response(EMPLOYEE_PROFILE_NOT_FOUND, status=status.HTTP_404_NOT_FOUND) + + serializer = ViewEmployeeInterestsSerializer(profile) + return Response(serializer.data, status=status.HTTP_200_OK) + +class ViewEmployeeProfileDescription(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def get(self, request): + try: + profile = request.user.employee_profile + except Exception: + return Response(EMPLOYEE_PROFILE_NOT_FOUND, status=404) + + serializer = ViewEmployeeProfileDescriptionSerializer(profile) + return Response(serializer.data, status=status.HTTP_200_OK) + +class UpdateEmployeeProfileDescriptionView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def put(self, request): + profile = request.user.employee_profile + + serializer = UpdateEmployeeProfileDescriptionSerializer( + instance=profile, + data=request.data, + context={'user': request.user} + ) + + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response({"message": "Perfil actualizado correctamente"}) + +class UpdateEmployeeEducationView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def put(self, request, pk): + try: + education = EducationCertification.objects.get(pk=pk, employee=request.user.employee_profile) + except EducationCertification.DoesNotExist: + return Response({"error": "Educacion no encontrada"}, status=404) + + serializer = UpdateEmployeeEducationSerializer( + education, + data=request.data, + context={'request': request} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response({"message": "Educación actualizada correctamente"}, status=200) + +class UpdateEmployeeWorkExperienceView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def put(self, request, pk): + try: + work_experience = WorkExperience.objects.get(pk=pk, employee=request.user.employee_profile) + except WorkExperience.DoesNotExist: + return Response({"error": "Experiencia laboral no encontrada"}, status=404) + + serializer = UpdateEmployeeWorkExperienceSerializer( + work_experience, + data=request.data, + context={'request': request} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response({"message": "Experiencia laboral actualizada correctamente"}, status=200) + +class DeleteEmployeeWorkExperienceView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def delete(self, request, pk): + try: + work_experience = WorkExperience.objects.get( + pk=pk, + employee=request.user.employee_profile + ) + except WorkExperience.DoesNotExist: + raise NotFound(WORK_EXPERIENCE_NOT_FOUND) + + work_experience.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + +class DeleteEmployeeEducationView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def delete(self, request, pk): + try: + education = EducationCertification.objects.get( + pk=pk, + employee=request.user.employee_profile + ) + except EducationCertification.DoesNotExist: + raise NotFound(EDUCATION_NOT_FOUND) + + + education.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/backend/user_auth/views/employer.py b/backend/user_auth/views/employer.py index bc37041b..60bb609f 100644 --- a/backend/user_auth/views/employer.py +++ b/backend/user_auth/views/employer.py @@ -1,8 +1,12 @@ +from django.forms import ValidationError from rest_framework import status, permissions from rest_framework.response import Response from rest_framework.views import APIView -from user_auth.serializers.employer import CompleteEmployerSocialSerializer, EmployerRegisterSerializer +from user_auth.constants import EMPLOYER_ROLE +from user_auth.models.employer import EmployerProfile +from user_auth.permissions import IsInGroup +from user_auth.serializers.employer import CompleteEmployerSocialSerializer, EmployerProfileDescriptionSerializer, EmployerRegisterSerializer, UpdateEmployerProfileSerializer, ViewEmployerProfileDescriptionSerializer class EmployerRegisterView(APIView): permission_classes = [permissions.AllowAny] @@ -23,4 +27,62 @@ def post(self, request): if serializer.is_valid(): serializer.save() return Response({'message': 'Employer profile completed'}, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class EmployerProfileDescriptionView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + + def put(self, request): + try: + profile = request.user.employer_profile + except EmployerProfile.DoesNotExist: + return Response({"detail": "Perfil de empleador no encontrado."}, status=404) + + serializer = EmployerProfileDescriptionSerializer(profile, data=request.data, partial=True) + try: + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + except ValidationError as e: + return Response({"errors": e.detail}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response({"detail": f"Unexpected error: {str(e)}"}, status=500) + +class ViewEmployerProfileDescription(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + + def get(self, request): + try: + profile = request.user.employer_profile + except EmployerProfile.DoesNotExist: + return Response({"detail": "Perfil de empleador no encontrado."}, status=404) + + serializer = ViewEmployerProfileDescriptionSerializer(profile) + return Response(serializer.data, status=status.HTTP_200_OK) + +class UpdateEmployerProfileDescriptionView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYER_ROLE] + + def put(self, request): + try: + profile = request.user.employer_profile + except EmployerProfile.DoesNotExist: + return Response("Perfil de empleador no encontrado.", status=404) + + serializer = UpdateEmployerProfileSerializer( + profile, + data=request.data, + partial=False, + context={'user': request.user} + ) + + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + serializer.save() + + return Response({"detail": "Perfil actualizado correctamente."}, status=200) \ No newline at end of file diff --git a/backend/user_auth/views/language.py b/backend/user_auth/views/language.py new file mode 100644 index 00000000..d10665a8 --- /dev/null +++ b/backend/user_auth/views/language.py @@ -0,0 +1,82 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import permissions, status +from user_auth.errors.language_errors import PROFILE_NOT_FOUND +from user_auth.models.employee import EmployeeProfile +from user_auth.models.language import EmployeeLanguage, LanguageLevel +from user_auth.serializers.language import ( + EmployeeLanguageSerializer, + LanguageLevelSerializer, + EmployeeLanguageCreateSerializer, +) +from user_auth.permissions import IsInGroup +from user_auth.constants import EMPLOYEE_ROLE + +from user_auth.utils import load_languages + + +class EmployeeLanguagesView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def get(self, request): + try: + profile = request.user.employee_profile + except EmployeeProfile.DoesNotExist: + return Response(PROFILE_NOT_FOUND, status=404) + + languages = EmployeeLanguage.objects.filter(employee=profile) + serializer = EmployeeLanguageSerializer(languages, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class LanguageLevelsView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + levels = LanguageLevel.objects.all() + serializer = LanguageLevelSerializer(levels, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class LanguagesListView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + idiomas = load_languages() + return Response(idiomas, status=status.HTTP_200_OK) + + +class EmployeeLanguagesBulkUpdateView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE] + + def put(self, request): + try: + profile = request.user.employee_profile + except EmployeeProfile.DoesNotExist: + return Response(PROFILE_NOT_FOUND, status=404) + + # Eliminar todos los idiomas previos + EmployeeLanguage.objects.filter(employee=profile).delete() + + # Crear los nuevos idiomas enviados + languages_data = ( + request.data if isinstance(request.data, list) + else request.data.get('languages', []) + ) + created = [] + errors = [] + for lang in languages_data: + serializer = EmployeeLanguageCreateSerializer(data=lang) + if serializer.is_valid(): + serializer.save(employee=profile) + created.append(serializer.data) + else: + errors.append(serializer.errors) + if errors: + return Response( + {'created': created, 'errors': errors}, + status=status.HTTP_207_MULTI_STATUS + ) + return Response(created, status=status.HTTP_200_OK) diff --git a/backend/user_auth/views/password_reset.py b/backend/user_auth/views/password_reset.py index d321a4b7..5ed96cbf 100644 --- a/backend/user_auth/views/password_reset.py +++ b/backend/user_auth/views/password_reset.py @@ -1,11 +1,12 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.generics import GenericAPIView +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string from ..utils import generate_otp from django.utils import timezone from datetime import timedelta -from django.core.mail import send_mail from user_auth.models.user import CustomUser from user_auth.models.validation import PasswordResetOTP from user_auth.serializers.password_reset import PasswordResetCompleteSerializer, PasswordResetRequestSerializer, PasswordResetVerifySerializer @@ -21,29 +22,51 @@ def post(self, request, *args, **kwargs): try: user = CustomUser.objects.get(email=email) except CustomUser.DoesNotExist: - # No revelamos si existe el email - return Response({"detail": "Si el email existe, se envió un código de recuperación."}, status=status.HTTP_200_OK) + return Response( + {"detail": "Si el email existe, se envió un código de recuperación."}, + status=status.HTTP_200_OK + ) # Generar OTP y guardar otp = generate_otp() - valid_until = timezone.now() + timedelta(minutes=5) # válido 5 minutos + valid_until = timezone.now() + timedelta(minutes=5) - # Guardar o actualizar OTP para el usuario PasswordResetOTP.objects.update_or_create( user=user, - defaults={ - 'otp_code': otp, - 'valid_until': valid_until - } + defaults={'otp_code': otp, 'valid_until': valid_until} ) - # Enviar mail con el OTP - subject = "Código para restablecer tu contraseña" - message = f"Hola {user.username},\n\nTu código para restablecer la contraseña es: {otp}\nEste código es válido por 5 minutos.\n\nSi no solicitaste este código, ignora este mensaje." - send_mail(subject, message, None, [user.email]) + # Preparar contexto y render HTML + context = {"username": user.username, "otp": otp, "valid_minutes": 5} + html_content = render_to_string("emails/password_reset.html", context) - return Response({"detail": "Si el email existe, se envió un código de recuperación."}, status=status.HTTP_200_OK) + # Texto plano como fallback + text_content = f""" + Hola, {user.username}: + Recibimos una solicitud para restablecer tu contraseña. + + Si no fuiste vos, ignorá este mensaje. + Usa este código para restablecer la contraseña: {otp} + + Este código es válido por {context['valid_minutes']} minutos. + + Gracias, + Jex + """ + # Enviar correo + subject = "Restablece tu contraseña" + from_email = None + to_email = [user.email] + + email_message = EmailMultiAlternatives(subject, text_content, from_email, to_email) + email_message.attach_alternative(html_content, "text/html") + email_message.send() + + return Response( + {"detail": "Si el email existe, se envió un código de recuperación."}, + status=status.HTTP_200_OK + ) class PasswordResetVerifyView(GenericAPIView): serializer_class = PasswordResetVerifySerializer diff --git a/backend/user_auth/views/phone_verification.py b/backend/user_auth/views/phone_verification.py index d52f21f2..117421e9 100644 --- a/backend/user_auth/views/phone_verification.py +++ b/backend/user_auth/views/phone_verification.py @@ -1,129 +1,95 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView - -# from user_auth.services.twilio import TwilioService -# from django.utils import timezone -# from datetime import timedelta -# from user_auth.models.validation import PhoneVerification -# from user_auth.serializers.phone_verification import SendCodeSerializer, VerifyCodeSerializer - -# class SendPhoneVerificationCodeView(APIView): -# permission_classes = [] - -# def post(self, request): -# serializer = SendCodeSerializer(data=request.data) -# if serializer.is_valid(): -# phone = serializer.validated_data['phone'] - -# # Check if phone number exists and is verified -# existing = PhoneVerification.objects.filter( -# phone=phone, -# is_verified=True -# ).first() - -# if existing: -# return Response({ -# 'error': 'This phone number is already verified.', -# }, status=status.HTTP_400_BAD_REQUEST) - -# # Send code using Twilio -# twilio_service = TwilioService() -# result = twilio_service.send_verification_code(phone) - -# if result['success']: -# # Create or update PhoneVerification entry -# phone_verification, created = PhoneVerification.objects.update_or_create( -# phone=phone, -# defaults={ -# 'is_verified': False, -# 'verified_at': None, -# 'expires_at': timezone.now() + timedelta(minutes=10) # 10 mins -# } -# ) - -# action = "enviado" if created else "reenviado" -# return Response({ -# 'message': f'Código {action} correctamente', -# 'phone': phone, -# 'expires_in_minutes': 10 -# }, status=status.HTTP_200_OK) -# else: -# return Response({ -# 'error': result['message'] -# }, status=status.HTTP_400_BAD_REQUEST) - -# return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - -# class VerifyPhoneCodeView(APIView): -# permission_classes = [] - -# def post(self, request): -# serializer = VerifyCodeSerializer(data=request.data) -# if serializer.is_valid(): -# phone = serializer.validated_data['phone'] -# code = serializer.validated_data['code'] - -# # Check if phone number exists and is not verified -# try: -# phone_verification = PhoneVerification.objects.get( -# phone=phone -# ) - -# # If exprired, reccomend to request a new code -# if timezone.now() > phone_verification.expires_at: -# return Response({ -# 'error': 'The code has expired. Please request a new code.', -# 'message': 'Request a new code', -# 'verified': False, -# 'expired': True -# }, status=status.HTTP_400_BAD_REQUEST) - -# except PhoneVerification.DoesNotExist: -# return Response({ -# 'error': 'First request a code', -# 'verified': False -# }, status=status.HTTP_400_BAD_REQUEST) - -# # Check the code using Twilio -# twilio_service = TwilioService() -# result = twilio_service.verify_code(phone, code) - -# if result['success']: -# # Mark as verified -# phone_verification.is_verified = True -# phone_verification.verified_at = timezone.now() -# phone_verification.save() - -# return Response({ -# 'message': 'Phone number verified successfully', -# 'verified': True -# }, status=status.HTTP_200_OK) -# else: -# return Response({ -# 'error': result['message'], -# 'verified': False -# }, status=status.HTTP_400_BAD_REQUEST) - -# return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - +from user_auth.models.user import CustomUser +from user_auth.services.twilio import TwilioService +from django.utils import timezone +from datetime import timedelta +from user_auth.models.validation import PhoneVerification +from user_auth.serializers.phone_verification import SendCodeSerializer, VerifyCodeSerializer class SendPhoneVerificationCodeView(APIView): - permission_classes = [] - + permission_classes = [] def post(self, request): - return Response({ - 'message': 'Código enviado correctamente (simulado)', - 'phone': request.data.get('phone', 'unknown'), - 'expires_in_minutes': 10 - }, status=status.HTTP_200_OK) - + serializer = SendCodeSerializer(data=request.data) + if serializer.is_valid(): + phone = serializer.validated_data['phone'] + # Validar si el teléfono ya está registrado + if CustomUser.objects.filter(phone=phone).exists(): + return Response( + {"error": "Ya existe un usuario registrado con este número de teléfono."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Send code using Twilio + twilio_service = TwilioService() + result = twilio_service.send_verification_code(phone) + if result['success']: + # Create or update PhoneVerification entry + phone_verification, created = PhoneVerification.objects.update_or_create( + phone=phone, + defaults={ + 'is_verified': False, + 'verified_at': None, + 'expires_at': timezone.now() + timedelta(minutes=10) + } + ) + action = "enviado" if created else "reenviado" + return Response({ + 'message': f'Código {action} correctamente', + 'phone': phone, + 'expires_in_minutes': 10 + }, status=status.HTTP_200_OK) + else: + return Response({ + 'error': result['message'] + }, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + class VerifyPhoneCodeView(APIView): - permission_classes = [] + permission_classes = [] def post(self, request): - return Response({ - 'message': 'Phone number verified successfully (simulado)', - 'verified': True - }, status=status.HTTP_200_OK) \ No newline at end of file + serializer = VerifyCodeSerializer(data=request.data) + if serializer.is_valid(): + phone = serializer.validated_data['phone'] + code = serializer.validated_data['code'] + # Check if phone number exists and is not verified + try: + phone_verification = PhoneVerification.objects.get( + phone=phone + ) + # If exprired, reccomend to request a new code + if timezone.now() > phone_verification.expires_at: + return Response({ + 'error': 'The code has expired. Please request a new code.', + 'message': 'Request a new code', + 'verified': False, + 'expired': True + }, status=status.HTTP_400_BAD_REQUEST) + except PhoneVerification.DoesNotExist: + return Response({ + 'error': 'First request a code', + 'verified': False + }, status=status.HTTP_400_BAD_REQUEST) + # Check the code using Twilio + twilio_service = TwilioService() + result = twilio_service.verify_code(phone, code) + if result['success']: + # Mark as verified + phone_verification.is_verified = True + phone_verification.verified_at = timezone.now() + phone_verification.save() + return Response({ + 'message': 'Phone number verified successfully', + 'verified': True + }, status=status.HTTP_200_OK) + else: + return Response({ + 'error': result['message'], + 'verified': False + }, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + diff --git a/backend/user_auth/views/user.py b/backend/user_auth/views/user.py new file mode 100644 index 00000000..df506c72 --- /dev/null +++ b/backend/user_auth/views/user.py @@ -0,0 +1,24 @@ +from rest_framework.response import Response +from rest_framework import permissions +from rest_framework.views import APIView +from user_auth.constants import EMPLOYEE_ROLE, EMPLOYER_ROLE +from user_auth.permissions import IsInGroup + +from user_auth.serializers.user import UserPublicProfileSerializer, ViewMailAndPhoneSerializer + + +class UserProfileView(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE, EMPLOYER_ROLE] + + def get(self, request): + serializer = UserPublicProfileSerializer(request.user) + return Response(serializer.data, status=200) + +class ViewMailAndPhone(APIView): + permission_classes = [permissions.IsAuthenticated, IsInGroup] + required_groups = [EMPLOYEE_ROLE, EMPLOYER_ROLE] + + def get(self, request): + serializer = ViewMailAndPhoneSerializer(request.user) + return Response(serializer.data, status=200) \ No newline at end of file diff --git a/backend/vacancies/constants.py b/backend/vacancies/constants.py index 9e65cea1..f8efae53 100644 --- a/backend/vacancies/constants.py +++ b/backend/vacancies/constants.py @@ -22,6 +22,20 @@ class JobTypesEnum(str, Enum): ANIMADOR = "Animador" OTRO = "Otro" +class JobTypeEnum2(Enum): + CATERING = "Catering" + JUEZ = "Juez" + MARKETING = "Marketing" + DJ = "DJ" + MAESTRO_CEREMONIAS = "Maestro de ceremonias" + CHOFER = "Chofer" + COCINERO = "Cocinero" + BARRISTA = "Barrista" + HOST = "Host / Anfitrión" + PERSONAL_BACKSTAGE = "Personal de backstage" + TECNICO_LUCES = "Técnico de luces" + TECNICO_SONIDO = "Técnico de sonido" + class Unaccent(Func): function = 'UNACCENT' diff --git a/backend/vacancies/migrations/0005_add_new_jobtype.py b/backend/vacancies/migrations/0005_add_new_jobtype.py new file mode 100644 index 00000000..284110ad --- /dev/null +++ b/backend/vacancies/migrations/0005_add_new_jobtype.py @@ -0,0 +1,48 @@ +from django.db import migrations + + +def create_new_jobtypes(apps, schema_editor): + JobType = apps.get_model('vacancies', 'JobType') + + try: + from vacancies.constants import JobTypeEnum2 + except Exception: + # Si no existe JobTypeEnum2 o hay error al importarlo, no hacemos nada + return + + for member in JobTypeEnum2: + name = str(member.value).strip() + if name: + JobType.objects.get_or_create(name=name) + + +def delete_new_jobtypes(apps, schema_editor): + """ + Reverse: eliminar los job types definidos en JobTypeEnum2. + Atención: esto borra por nombre exacto. Si un jobtype con ese nombre existía + antes de ejecutar la migración, también será eliminado. + """ + JobType = apps.get_model('vacancies', 'JobType') + + try: + from vacancies.constants import JobTypeEnum2 + except Exception: + # Si el enum no existe en el momento del rollback, no eliminar nada + return + + names = [str(member.value).strip() for member in JobTypeEnum2 if str(member.value).strip()] + if not names: + return + + JobType.objects.filter(name__in=names).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('vacancies', '0004_remove_shift_qr_enabled'), + ] + + operations = [ + migrations.RunPython(create_new_jobtypes, reverse_code=delete_new_jobtypes), + ] \ No newline at end of file diff --git a/backend/vacancies/serializers/shifts.py b/backend/vacancies/serializers/shifts.py index 055c6719..8a059d7c 100644 --- a/backend/vacancies/serializers/shifts.py +++ b/backend/vacancies/serializers/shifts.py @@ -29,10 +29,23 @@ class ShiftSerializer(serializers.ModelSerializer): end_date = CustomDateField() start_time = CustomTimeField() end_time = CustomTimeField() + already_applied = serializers.SerializerMethodField() class Meta: model = Shift - fields = ['id', 'start_date', 'end_date', 'start_time', 'end_time', 'payment', 'quantity'] + fields = ['id', 'start_date', 'end_date', 'start_time', 'end_time', 'payment', 'quantity', 'already_applied'] + + def get_already_applied(self, obj): + """ + Devuelve True si el empleado actual ya tiene una aplicación para este shift. + """ + user = self.context.get('request').user + try: + employee_profile = user.employee_profile + except AttributeError: + return False + + return obj.applications.filter(employee=employee_profile).exists() class ShiftDetailForOfferByStateSerializer(serializers.ModelSerializer): diff --git a/backend/vacancies/serializers/vacancy.py b/backend/vacancies/serializers/vacancy.py index da1e78ba..81db6c7f 100644 --- a/backend/vacancies/serializers/vacancy.py +++ b/backend/vacancies/serializers/vacancy.py @@ -62,22 +62,20 @@ def _validate_shifts(self, data): if not event or not shifts: return - event_start = datetime.combine(event.start_date, event.start_time) - event_end = datetime.combine(event.end_date, event.end_time) - for i, shift in enumerate(shifts): - shift_start = datetime.combine(shift['start_date'], shift['start_time']) - shift_end = datetime.combine(shift['end_date'], shift['end_time']) + shift_start_date = shift['start_date'] + shift_end_date = shift['end_date'] - if shift_start < event_start or shift_end > event_end: + # Validación: que las fechas del turno estén dentro del rango del evento + if shift_start_date < event.start_date or shift_end_date > event.end_date: raise serializers.ValidationError( - SHIFTS_OUT_OF_EVENT.format(shift_number=i+1) - ) + SHIFTS_OUT_OF_EVENT.format(shift_number=i + 1) + ) - # Validación: inicio < fin - if shift_start >= shift_end: + # Validación básica: fecha de inicio <= fecha de fin + if shift_start_date > shift_end_date: raise serializers.ValidationError( - SHIFTS_START_AFTER_END.format(shift_number=i+1) + SHIFTS_START_AFTER_END.format(shift_number=i + 1) ) def _validate_job_type(self, data): diff --git a/backend/vacancies/services/vacancy_list_service.py b/backend/vacancies/services/vacancy_list_service.py index 3c40005d..caa72c59 100644 --- a/backend/vacancies/services/vacancy_list_service.py +++ b/backend/vacancies/services/vacancy_list_service.py @@ -1,9 +1,12 @@ from django.db.models import OuterRef, Subquery from django.utils import timezone +from eventos.constants import EventStates from vacancies.models.shifts import Shift from vacancies.constants import VacancyStates from vacancies.utils import is_event_near +from datetime import timedelta + class VacancyListService: @@ -20,8 +23,9 @@ def get_base_queryset(): best_shift_per_event = Shift.objects.filter( vacancy__event=OuterRef('vacancy__event'), vacancy__state__name=VacancyStates.ACTIVE.value, - start_date__gte=today - ).order_by('-payment', 'start_date') # primero pago alto, luego más próximo + start_date__gte=today, + vacancy__event__state__name=EventStates.PUBLISHED.value, + ).order_by('-payment', 'start_date') qs = Shift.objects.filter( id=Subquery(best_shift_per_event.values('id')[:1]) @@ -46,9 +50,16 @@ def filter_by_interests(queryset, user): @staticmethod def filter_by_soon(queryset): """ - Ordena los turnos por fecha de inicio más próxima + Devuelve los turnos próximos dentro de los próximos 30 días, + ordenados por fecha de inicio (más cercana primero). """ - return queryset.order_by('start_date') + today = timezone.now().date() + next_30_days = today + timedelta(days=30) + + return queryset.filter( + start_date__gte=today, + start_date__lte=next_30_days + ).order_by('start_date') @staticmethod diff --git a/backend/vacancies/services/vacancy_search_service.py b/backend/vacancies/services/vacancy_search_service.py index 319304be..e091ca29 100644 --- a/backend/vacancies/services/vacancy_search_service.py +++ b/backend/vacancies/services/vacancy_search_service.py @@ -3,7 +3,7 @@ from django.db.models import Q from unidecode import unidecode from django.db.models.functions import Lower - +from django.db.models import Max, Min class VacancySearchService: @@ -15,7 +15,7 @@ def get_base_queryset(): """ return ( Vacancy.objects - .select_related("event", "event__event_image","job_type", "state") + .select_related("event", "event__event_image", "job_type", "state") .prefetch_related("shifts") .filter(state__name=VacancyStates.ACTIVE.value) ) @@ -87,7 +87,25 @@ def filter_by_date_range(queryset, date_from, date_to): def order_queryset(queryset, order_key): """ Ordena el queryset según un campo permitido en ORDERING_MAP. - Por defecto, ordena por id. + Soporta agregación para campos relacionados (shifts__payment, shifts__start_date) """ - order_field = ORDERING_MAP.get(order_key) if order_key else None - return queryset.order_by(order_field or "id") \ No newline at end of file + order_field = ORDERING_MAP.get(order_key) + if not order_field: + return queryset.order_by("id") # orden por defecto + + # Ordenamiento por payment + if "payment" in order_field: + queryset = queryset.annotate(max_payment=Max("shifts__payment")) + if order_field.startswith("-"): + return queryset.order_by("-max_payment") + return queryset.order_by("max_payment") + + # Ordenamiento por start_date + if "start_date" in order_field: + queryset = queryset.annotate(min_start=Min("shifts__start_date")) + if order_field.startswith("-"): + return queryset.order_by("-min_start") + return queryset.order_by("min_start") + + # Ordenamiento por otros campos normales + return queryset.order_by(order_field) \ No newline at end of file diff --git a/backend/vacancies/views/vacancy_state.py b/backend/vacancies/views/vacancy_state.py index 21b6979c..ffef8a65 100644 --- a/backend/vacancies/views/vacancy_state.py +++ b/backend/vacancies/views/vacancy_state.py @@ -1,6 +1,9 @@ from rest_framework.views import APIView from rest_framework import status, permissions from rest_framework.response import Response +from eventos.constants import EventStates +from eventos.models.state_events import EventState +from vacancies.constants import VacancyStates from vacancies.errors.vacancies_messages import VACANCY_NOT_FOUND, NO_PERMISSION_EVENT, STATE_UPDATED_SUCCESS from vacancies.models.vacancy import Vacancy from vacancies.models.vacancy_state import VacancyState @@ -38,4 +41,13 @@ def patch(self, request, pk): vacancy.state = new_state vacancy.save() + # Si la vacante se publica y el evento está en borrador, publicamos el evento + if new_state.name == VacancyStates.ACTIVE.value: + event = vacancy.event + draft_state = EventState.objects.get(name=EventStates.DRAFT.value) + if event.state.name == draft_state.name: + published_state = EventState.objects.get(name=EventStates.PUBLISHED.value) + event.state = published_state + event.save() + return Response({"detail": STATE_UPDATED_SUCCESS}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index 63d8074a..0e5d3817 100644 Binary files a/frontend/.gitignore and b/frontend/.gitignore differ diff --git a/frontend/android/.gitignore b/frontend/android/.gitignore new file mode 100644 index 00000000..8a6be077 --- /dev/null +++ b/frontend/android/.gitignore @@ -0,0 +1,16 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# Bundle artifacts +*.jsbundle diff --git a/frontend/android/app/build.gradle b/frontend/android/app/build.gradle new file mode 100644 index 00000000..190a1895 --- /dev/null +++ b/frontend/android/app/build.gradle @@ -0,0 +1,184 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() + // Use Expo CLI to bundle the app, this ensures the Metro config + // works correctly with Expo projects. + cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization). + */ +def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean() + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace 'com.jexapp' + defaultConfig { + applicationId 'com.jexapp' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0.0" + + buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' + shrinkResources enableShrinkResources.toBoolean() + minifyEnabled enableMinifyInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true' + crunchPngs enablePngCrunchInRelease.toBoolean() + } + } + packagingOptions { + jniLibs { + def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false' + useLegacyPackaging enableLegacyPackaging.toBoolean() + } + } + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } +} + +// Apply static values from `gradle.properties` to the `android.packagingOptions` +// Accepts values in comma delimited lists, example: +// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini +["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop -> + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(","); + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; + def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; + def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; + + if (isGifEnabled) { + // For animated gif support + implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} + +apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/frontend/android/app/debug.keystore b/frontend/android/app/debug.keystore new file mode 100644 index 00000000..364e105e Binary files /dev/null and b/frontend/android/app/debug.keystore differ diff --git a/frontend/android/app/google-services.json b/frontend/android/app/google-services.json new file mode 100644 index 00000000..083285ae --- /dev/null +++ b/frontend/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "470772912743", + "project_id": "jexnotificationspush", + "storage_bucket": "jexnotificationspush.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:470772912743:android:edd56cf1cc648ccef7fac2", + "android_client_info": { + "package_name": "com.jexapp" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBR0ipvO1j3PsxuaKrhD_7KPuzfLPt_pfU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/frontend/android/app/proguard-rules.pro b/frontend/android/app/proguard-rules.pro new file mode 100644 index 00000000..551eb41d --- /dev/null +++ b/frontend/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# react-native-reanimated +-keep class com.swmansion.reanimated.** { *; } +-keep class com.facebook.react.turbomodule.** { *; } + +# Add any project specific keep options here: diff --git a/frontend/android/app/src/debug/AndroidManifest.xml b/frontend/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..3ec2507b --- /dev/null +++ b/frontend/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/frontend/android/app/src/debugOptimized/AndroidManifest.xml b/frontend/android/app/src/debugOptimized/AndroidManifest.xml new file mode 100644 index 00000000..3ec2507b --- /dev/null +++ b/frontend/android/app/src/debugOptimized/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..ae4e38ce --- /dev/null +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/java/com/jexapp/MainActivity.kt b/frontend/android/app/src/main/java/com/jexapp/MainActivity.kt new file mode 100644 index 00000000..d1b0b044 --- /dev/null +++ b/frontend/android/app/src/main/java/com/jexapp/MainActivity.kt @@ -0,0 +1,65 @@ +package com.jexapp +import expo.modules.splashscreen.SplashScreenManager + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + // setTheme(R.style.AppTheme); + // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af + SplashScreenManager.registerOnActivity(this) + // @generated end expo-splashscreen + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/frontend/android/app/src/main/java/com/jexapp/MainApplication.kt b/frontend/android/app/src/main/java/com/jexapp/MainApplication.kt new file mode 100644 index 00000000..0202858a --- /dev/null +++ b/frontend/android/app/src/main/java/com/jexapp/MainApplication.kt @@ -0,0 +1,56 @@ +package com.jexapp + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.common.ReleaseLevel +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint +import com.facebook.react.defaults.DefaultReactNativeHost + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + } + ) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + DefaultNewArchitectureEntryPoint.releaseLevel = try { + ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) + } catch (e: IllegalArgumentException) { + ReleaseLevel.STABLE + } + loadReactNative(this) + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 00000000..5ad569e0 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 00000000..773bcaa5 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 00000000..aff7a7a9 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 00000000..6b7da186 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 00000000..5e3fcdf4 Binary files /dev/null and b/frontend/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..883b2a08 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/drawable/rn_edit_text_material.xml b/frontend/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 00000000..5c25e728 --- /dev/null +++ b/frontend/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..3941bea9 --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..3941bea9 --- /dev/null +++ b/frontend/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..a72fcb19 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..0d00dcde Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..afde9a7e Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..5715c656 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..08df4207 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..c415f537 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..e8b75a69 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..3faeaa78 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..47a61e61 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..f2e9697c Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..7d5ee986 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..acf80e47 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..2a5afeaf Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..a22d677e Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..e11a8cf7 Binary files /dev/null and b/frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/frontend/android/app/src/main/res/values-night/colors.xml b/frontend/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..3c05de5b --- /dev/null +++ b/frontend/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/values/colors.xml b/frontend/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f387b901 --- /dev/null +++ b/frontend/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #ffffff + #ffffff + #023c69 + #ffffff + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/values/strings.xml b/frontend/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..821c7e95 --- /dev/null +++ b/frontend/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Jex + contain + false + \ No newline at end of file diff --git a/frontend/android/app/src/main/res/values/styles.xml b/frontend/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..45a97e61 --- /dev/null +++ b/frontend/android/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/frontend/android/build.gradle b/frontend/android/build.gradle new file mode 100644 index 00000000..e39704ad --- /dev/null +++ b/frontend/android/build.gradle @@ -0,0 +1,25 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.google.gms:google-services:4.4.1' + classpath('com.android.tools.build:gradle') + classpath('com.facebook.react:react-native-gradle-plugin') + classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} + +apply plugin: "expo-root-project" +apply plugin: "com.facebook.react.rootproject" diff --git a/frontend/android/gradle.properties b/frontend/android/gradle.properties new file mode 100644 index 00000000..8e39f82e --- /dev/null +++ b/frontend/android/gradle.properties @@ -0,0 +1,65 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false + +# Specifies whether the app is configured to use edge-to-edge via the app config or plugin +# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge. +expo.edgeToEdgeEnabled=true diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.jar b/frontend/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/frontend/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/frontend/android/gradle/wrapper/gradle-wrapper.properties b/frontend/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..d4081da4 --- /dev/null +++ b/frontend/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/frontend/android/gradlew b/frontend/android/gradlew new file mode 100644 index 00000000..7f94d3d4 --- /dev/null +++ b/frontend/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/frontend/android/gradlew.bat b/frontend/android/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/frontend/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/frontend/android/settings.gradle b/frontend/android/settings.gradle new file mode 100644 index 00000000..ed2d79b4 --- /dev/null +++ b/frontend/android/settings.gradle @@ -0,0 +1,39 @@ +pluginManagement { + def reactNativeGradlePlugin = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + }.standardOutput.asText.get().trim() + ).getParentFile().absolutePath + includeBuild(reactNativeGradlePlugin) + + def expoPluginsPath = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") + }.standardOutput.asText.get().trim(), + "../android/expo-gradle-plugin" + ).absolutePath + includeBuild(expoPluginsPath) +} + +plugins { + id("com.facebook.react.settings") + id("expo-autolinking-settings") +} + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) + } +} +expoAutolinking.useExpoModules() + +rootProject.name = 'Jex' + +expoAutolinking.useExpoVersionCatalog() + +include ':app' +includeBuild(expoAutolinking.reactNativeGradlePlugin) diff --git a/frontend/app.config.ts b/frontend/app.config.ts new file mode 100644 index 00000000..a61268a0 --- /dev/null +++ b/frontend/app.config.ts @@ -0,0 +1,92 @@ +import 'dotenv/config'; +import type { ExpoConfig } from '@expo/config'; + +export default (): ExpoConfig => ({ + name: "Jex", + slug: "jex", + version: "1.0.0", + orientation: "portrait", + scheme: "jex", + userInterfaceStyle: "automatic", + newArchEnabled: true, + owner: "jex-app", + icon: "./assets/images/jex/Jex-Logo.png", + jsEngine: "hermes", + splash: { + image: "./assets/images/jex/Jex-Logo.png", + resizeMode: "contain", + backgroundColor: "#ffffff" + }, + android: { + package: "com.jexapp", + googleServicesFile: "./google-services.json", + versionCode: 1, + adaptiveIcon: { + foregroundImage: "./assets/images/jex/Jex-Logo.png", + backgroundColor: "#ffffff" + }, + edgeToEdgeEnabled: true, + splash: { + image: "./assets/images/jex/Jex-Logo.png", + resizeMode: "contain", + backgroundColor: "#ffffff", + }, + permissions: [ + "INTERNET", + "CAMERA", + "RECORD_AUDIO", + "READ_MEDIA_IMAGES", + "POST_NOTIFICATIONS" + ], + config: { + googleMaps: { apiKey: process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY } + } + }, + ios: { + supportsTablet: true, + bundleIdentifier: "com.jexapp", + splash: { + image: "./assets/images/jex/Jex-Logo.png", + resizeMode: "contain", + backgroundColor: "#ffffff" + }, + config: { + googleMapsApiKey: process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY + }, + infoPlist: { + NSCameraUsageDescription: "Necesitamos la cámara para escanear el QR y tomar fotos.", + NSMicrophoneUsageDescription: "Necesitamos el micrófono para grabar mensajes de voz.", + NSPhotoLibraryUsageDescription: "Necesitamos acceder a tus fotos para adjuntar imágenes.", + NSPhotoLibraryAddUsageDescription: "Necesitamos guardar imágenes/audios si elegís descargarlos." + } + }, + web: { + bundler: "metro", + output: "static" + }, + plugins: [ + "expo-router", + "expo-video", + ["expo-splash-screen", { + image: "./assets/images/jex/Jex-Logo.png", + imageWidth: 200, + resizeMode: "contain", + backgroundColor: "#ffffff" + }], + "expo-web-browser", + "@react-native-google-signin/google-signin", + "expo-secure-store", + ["expo-notifications", { mode: "production" }], + ["expo-camera", { + cameraPermission: "Usamos la cámara para la verificación de identidad.", + microphonePermission: "Usamos el micrófono si el proveedor lo requiere durante la verificación." + }] + ], + experiments: { + typedRoutes: true + }, + extra: { + router: {}, + eas: { projectId: "2664b647-4200-4d00-b8bc-e47ddb4cda05" } + } +}); diff --git a/frontend/app.json b/frontend/app.json deleted file mode 100644 index ac6efc8a..00000000 --- a/frontend/app.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "expo": { - "name": "Jex", - "slug": "jex", - "version": "1.0.0", - "orientation": "portrait", - "scheme": "jex", - "userInterfaceStyle": "automatic", - "newArchEnabled": true, - "owner": "jex-app", - "icon": "./assets/images/jex/Jex-Logo.png", - "jsEngine": "jsc", - "splash": { - "image": "./assets/images/jex/Jex-Logo.png", - "resizeMode": "contain", - "backgroundColor": "#ffffff" - }, - "android": { - "package": "com.jexapp", - "versionCode": 1, - "adaptiveIcon": { - "foregroundImage": "./assets/images/jex/Jex-Logo.png", - "backgroundColor": "#ffffff" - }, - "edgeToEdgeEnabled": true, - "splash": { - "image": "./assets/images/jex/Jex-Logo.png", - "resizeMode": "contain", - "backgroundColor": "#ffffff" - } - }, - "ios": { - "supportsTablet": true, - "bundleIdentifier": "com.jexapp", - "splash": { - "image": "./assets/images/jex/Jex-Logo.png", - "resizeMode": "contain", - "backgroundColor": "#ffffff" - } - }, - "web": { - "bundler": "metro", - "output": "static" - }, - "plugins": [ - "expo-router", - [ - "expo-splash-screen", - { - "image": "./assets/images/jex/Jex-Logo.png", - "imageWidth": 200, - "resizeMode": "contain", - "backgroundColor": "#ffffff" - } - ], - "expo-web-browser", - "expo-secure-store" - ], - "experiments": { - "typedRoutes": true - }, - "extra": { - "router": {}, - "eas": { - "projectId": "2664b647-4200-4d00-b8bc-e47ddb4cda05" - } - } - } -} diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 651fb7ae..bd4c1cdf 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -5,23 +5,40 @@ import { SplashScreen, Stack } from 'expo-router'; import React, { useEffect } from 'react'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import * as WebBrowser from 'expo-web-browser'; +import * as Linking from 'expo-linking'; +import { useDeepLinkDebug } from '@/services/internal/useDeepLinkDebug'; WebBrowser.maybeCompleteAuthSession(); export default function RootLayout() { + useDeepLinkDebug(); + const [fontsLoaded] = useFonts(fontMap); + // Calienta el Custom Tab / SFSafariViewController y luego lo enfría + useEffect(() => { + WebBrowser.warmUpAsync(); + return () => { WebBrowser.coolDownAsync(); }; + }, []); + + // Debug de deep links: confirmá que llega jex://oauthredirect + useEffect(() => { + const sub = Linking.addEventListener('url', ({ url }) => { + console.log('DEEPLINK capturado =>', url); + }); + return () => sub.remove(); + }, []); + useEffect(() => { if (fontsLoaded) SplashScreen.hideAsync(); }, [fontsLoaded]); if (!fontsLoaded) return null; + return ( - - - + + - ); } diff --git a/frontend/app/auth/_layout.tsx b/frontend/app/auth/_layout.tsx index 3e368d6f..9e11cdbb 100644 --- a/frontend/app/auth/_layout.tsx +++ b/frontend/app/auth/_layout.tsx @@ -4,6 +4,7 @@ import { Ionicons } from '@expo/vector-icons'; import { router, Stack } from 'expo-router'; import { Pressable } from 'react-native'; import { Colors } from '@/themes/colors'; +import React from 'react'; export default function AuthLayout() { return ( @@ -18,6 +19,22 @@ export default function AuthLayout() { ), }} > + + + + + {/* Header: Stepper + Omitir */} + + Personalizá tu Algoritmo + + + {/* Body */} + + Elegí hasta 3 intereses para mejorar tus coincidencias + + + {intereses.map((interes) => ( + handleToggleIntereses(interes.id)} + styles={selectableTagStyles2} + /> + ))} + + + + + {/* Footer: Siguiente */} + +