diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ccca65ada..cceca0159 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -402,13 +402,13 @@ docker compose --env-file .env.dev up backend --build -d # -d to hide logs docker exec -it django_backend sh # Run backend tests: -pytest +uv run pytest # To run a specific test: -pytest path/to/test_file.py::test_function +uv run pytest path/to/test_file.py::test_function # To run with a coverage report as is done in PRs: -pytest --cov --cov-report=term-missing --cov-config=pyproject.toml -vv +uv run pytest --cov --cov-report=term-missing --cov-config=pyproject.toml -vv # Once tests are finished: exit diff --git a/backend/Dockerfile b/backend/Dockerfile index 15a348234..267edeaf0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,10 @@ WORKDIR /app # Install uv. COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +# Copy from the cache instead of linking since it's a mounted volume. +ENV UV_LINK_MODE=copy +ENV UV_SYSTEM_PYTHON=true + # Install dependencies. COPY ./pyproject.toml ./uv.lock* ./ RUN uv sync --frozen --no-dev diff --git a/backend/communities/admin.py b/backend/communities/admin.py index 15b2484dd..380874817 100644 --- a/backend/communities/admin.py +++ b/backend/communities/admin.py @@ -50,7 +50,7 @@ class GroupAdmin(admin.ModelAdmin): # type: ignore[type-arg] class Meta: model = Group - list_display = ["group_name", "name"] + list_display = ["name"] # MARK: Group Text @@ -98,7 +98,7 @@ class OrganizationAdmin(admin.ModelAdmin): # type: ignore[type-arg] class Meta: model = Organization - list_display = ["org_name", "name"] + list_display = ["name"] # MARK: Org Text diff --git a/backend/communities/groups/filters.py b/backend/communities/groups/filters.py index 138ae6464..608d163ff 100644 --- a/backend/communities/groups/filters.py +++ b/backend/communities/groups/filters.py @@ -18,6 +18,7 @@ class GroupFilter(django_filters.FilterSet): # type: ignore[misc] field_name="org", to_field_name="id", queryset=Organization.objects.all(), + conjoined=False, ) class Meta: diff --git a/backend/communities/groups/models.py b/backend/communities/groups/models.py index 4e8abbc03..30e68464c 100644 --- a/backend/communities/groups/models.py +++ b/backend/communities/groups/models.py @@ -25,12 +25,12 @@ class Group(models.Model): related_name="groups", null=False, ) + description = models.CharField(max_length=2500, blank=True, default="") created_by = models.ForeignKey( "authentication.UserModel", on_delete=models.CASCADE, related_name="created_group", ) - group_name = models.CharField(max_length=255) name = models.CharField(max_length=255) tagline = models.CharField(max_length=255, blank=True) icon_url = models.OneToOneField( diff --git a/backend/communities/groups/serializers.py b/backend/communities/groups/serializers.py index c3e6856e7..f17aa308e 100644 --- a/backend/communities/groups/serializers.py +++ b/backend/communities/groups/serializers.py @@ -7,6 +7,7 @@ from typing import Any from uuid import UUID +from django.db import IntegrityError, OperationalError, transaction from rest_framework import serializers from communities.groups.models import ( @@ -20,8 +21,8 @@ GroupText, ) from communities.organizations.models import Organization -from content.models import Topic -from content.serializers import LocationSerializer +from content.models import Location, Topic +from content.serializers import LocationSerializer, TopicSerializer from events.serializers import EventSerializer logger = logging.getLogger(__name__) @@ -196,28 +197,84 @@ class Meta: # MARK: POST -class GroupPOSTSerializer(serializers.ModelSerializer[Group]): +class GroupPOSTSerializer(serializers.Serializer[Group]): """ Serializer for creating groups with related fields. """ - texts = GroupTextSerializer(write_only=True, required=False) - social_links = GroupSocialLinkSerializer(write_only=True, required=False) - location = LocationSerializer(write_only=True) - org_id = serializers.PrimaryKeyRelatedField( - queryset=Organization.objects.all(), source="org" - ) + name = serializers.CharField(max_length=255) + tagline = serializers.CharField(max_length=255, required=False, allow_blank=True) + description = serializers.CharField(max_length=2500) + org = serializers.UUIDField() + topics = TopicSerializer(many=True, required=False) + country_code = serializers.CharField(max_length=3) + city = serializers.CharField(max_length=255) - class Meta: - model = Group + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + """ + Validate the data being posted. - exclude = ( - "topics", - "org", - "created_by", - "category", - "icon_url", - ) + Parameters + ---------- + data : dict[str, Any] + The data to be posted. + + Returns + ------- + dict[str, Any] + The data post validation. + """ + return data + + def create(self, validated_data: dict[str, Any]) -> Group: + """ + Create a group via a post operation. + + Parameters + ---------- + validated_data : dict[str, Any] + Data to be used in the creation of a group. + + Returns + ------- + Organization + The group object that was created in the database. + """ + with transaction.atomic(): + city = validated_data.pop("city") + country_code = validated_data.pop("country_code") + description = validated_data.pop("description", "") + # iso = validated_data.pop("iso") + + location_data = { + "city": city, + "country_code": country_code, + "lat": "", + "lon": "", + } + location = Location.objects.create(**location_data) + + try: + org = Organization.objects.get(id=validated_data.pop("org")) + group = Group.objects.create( + location=location, org=org, **validated_data + ) + + group_text = GroupText.objects.create( + group=group, + # iso=iso, + primary=True, + description=description, + ) + group.texts.set([group_text]) + + logger.info("Created Group with id: %s", group.id) + + return group + + except (Organization.DoesNotExist, IntegrityError, OperationalError) as e: + location.delete() + raise e # MARK: Group diff --git a/backend/communities/groups/tests/resource/test_group_resource.py b/backend/communities/groups/tests/resource/test_group_resource.py index eab4faf10..d57727e70 100644 --- a/backend/communities/groups/tests/resource/test_group_resource.py +++ b/backend/communities/groups/tests/resource/test_group_resource.py @@ -49,7 +49,6 @@ def test_group_resource_serializer() -> None: org=org, created_by=user, name="Test Group", - group_name="Test Group", tagline="Test tagline", location=location, category="test", @@ -95,7 +94,6 @@ def test_validate_group_with_group_instance_resource_serializer(): group = Group.objects.create( org=org, created_by=user, - group_name=fake.company(), name=fake.company(), tagline=fake.catch_phrase(), location=location, @@ -121,7 +119,6 @@ def test_validate_group_with_valid_uuid_resource_serializer(): group = Group.objects.create( org=org, created_by=user, - group_name=fake.company(), name=fake.company(), tagline=fake.catch_phrase(), location=location, diff --git a/backend/communities/groups/tests/test_group.py b/backend/communities/groups/tests/test_group.py index b381050b4..2286edde3 100644 --- a/backend/communities/groups/tests/test_group.py +++ b/backend/communities/groups/tests/test_group.py @@ -30,7 +30,6 @@ def test_group_create() -> None: group = Group.objects.create( org=org, created_by=user, - group_name=fake.company(), name=fake.company(), tagline=fake.catch_phrase(), location=location, @@ -41,7 +40,6 @@ def test_group_create() -> None: assert isinstance(group.id, UUID) assert group.org == org assert group.created_by == user - assert isinstance(group.group_name, str) assert isinstance(group.creation_date, datetime) assert group.terms_checked is True @@ -58,7 +56,6 @@ def test_group_multiple_groups_per_org() -> None: group1 = Group.objects.create( org=org, created_by=user, - group_name=fake.company(), name=fake.company(), location=location, category=fake.word(), @@ -68,7 +65,6 @@ def test_group_multiple_groups_per_org() -> None: group2 = Group.objects.create( org=org, created_by=user, - group_name=fake.company(), name=fake.company(), location=location, category=fake.word(), diff --git a/backend/communities/groups/tests/test_group_api.py b/backend/communities/groups/tests/test_group_api.py deleted file mode 100644 index 97ba88928..000000000 --- a/backend/communities/groups/tests/test_group_api.py +++ /dev/null @@ -1,226 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -from pathlib import Path -from typing import Any, TypedDict - -import pytest -from django.conf import settings -from django.core.management import call_command -from rest_framework.test import APIClient - -from authentication.factories import UserFactory -from authentication.models import UserModel -from communities.groups.factories import GroupFactory -from communities.groups.models import Group -from communities.organizations.factories import OrganizationFactory -from content.factories import EntityLocationFactory - - -class UserDict(TypedDict): - user: UserModel - plaintext_password: str - - -def create_user(password: str) -> UserDict: - """ - Create a user and return the user and password. - """ - user = UserFactory.create(plaintext_password=password, is_confirmed=True) - return {"user": user, "plaintext_password": password} - - -def login_user(user_data: UserDict) -> dict[Any, Any]: - """ - Log in a user. Returns the user and token. - """ - client = APIClient() - - response = client.post( - "/v1/auth/sign_in", - { - "username": user_data["user"].username, - "password": user_data["plaintext_password"], - }, - ) - assert response.status_code == 200 - return {"user": user_data["user"], "access": response.data["access"]} - - -@pytest.fixture(scope="session") -def status_types(django_db_setup, django_db_blocker) -> None: - """ - Load the status_types fixture into the test database. - """ - with django_db_blocker.unblock(): - fixture_path = Path(settings.BASE_DIR) / "fixtures" / "status_types.json" - call_command("loaddata", str(fixture_path), verbosity=2) - - -@pytest.fixture -def new_user() -> UserDict: - return create_user("Activist@123!?") - - -@pytest.fixture -def created_by_user() -> UserModel: - """ - Create a user and return the user object. - """ - return create_user("Creator@123!?")["user"] - - -@pytest.fixture -def logged_in_user(new_user) -> dict[Any, Any]: - """ - Create a user and log in the user. - """ - return login_user(new_user) - - -@pytest.fixture -def logged_in_created_by_user(created_by_user) -> dict: - return login_user({"user": created_by_user, "plaintext_password": "Creator@123!?"}) - - -@pytest.mark.django_db -def test_GroupAPIView(logged_in_user, status_types): - """ - Test OrganizationAPIView - - # GET request - - 1. Verify the number of groups in the database - 2. Test the list view endpoint - 3. Check if the pagination keys are present - 4. Test if query_param page_size is working properly - 5. Verify the number of groups in the response matches the page_size - 6. Check the pagination links in the response - - # POST request - - 1. Create a new organization with a valid payload - 2. Verify the response status code is 201 (Created) - """ - client = APIClient() - - number_of_groups = 10 - test_page_size = 1 - - # MARK: List GET - - GroupFactory.create_batch(number_of_groups) - assert Group.objects.count() == number_of_groups - - response = client.get("/v1/communities/groups") - assert response.status_code == 200 - - pagination_keys = ["count", "next", "previous", "results"] - assert all(key in response.data for key in pagination_keys) - - response = client.get(f"{'/v1/communities/groups'}?pageSize={test_page_size}") - assert response.status_code == 200 - - assert len(response.data["results"]) == test_page_size - assert response.data["previous"] is None - assert response.data["next"] is not None - - # MARK: List POST - - # Not Authenticated. - response = client.post("/v1/communities/groups") - assert response.status_code == 401 - - # Authenticated and successful. - org = OrganizationFactory.create(org_name="test_org", terms_checked=True) - new_group = GroupFactory.build(group_name="new_group", terms_checked=True) - location = EntityLocationFactory.build() - token = logged_in_user["access"] - - payload = { - "org_id": org.id, - "group_name": new_group.name, - "name": new_group.name, - "tagline": new_group.tagline, - "location": { - "id": location.id, - "lat": location.lat, - "lon": location.lon, - "bbox": location.bbox, - "display_name": location.display_name, - }, - "category": new_group.category, - "terms_checked": new_group.terms_checked, - } - - client.credentials(HTTP_AUTHORIZATION=f"Token {token}") - response = client.post("/v1/communities/groups", data=payload, format="json") - - assert response.status_code == 201 - assert Group.objects.filter(name=new_group.name).exists() - - -@pytest.mark.django_db -def test_GroupDetailAPIView(logged_in_user, logged_in_created_by_user) -> None: - """ - Test GroupDetailAPIView - - # GET request - - 1. Create a new group and verify it exists in the database. - 2. Test the detail view endpoint for the group. - 3. Verify the response status code is 200 (OK). - 4. Ensure the response data matches the group data. - - # PUT request - - 1. Check if groups can be edited without the proper credentials - 2. Update the group with the created_by user and verify the changes are saved. - - # DELETE request - - 1. Check if groups can be deleted without the proper credentials - 2. Delete the group with the created_by user and verify it is removed from the database. - """ - client = APIClient() - - created_by_user, access = logged_in_created_by_user.values() - - new_group = GroupFactory.create(created_by=created_by_user) - assert Group.objects.filter(group_name=new_group.group_name).exists() - - # MARK: Detail GET - - response = client.get(f"{'/v1/communities/groups'}/{new_group.id}") - assert response.status_code == 200 - assert response.data["group_name"] == new_group.group_name - - # MARK: Detail PUT - - updated_payload = {"group_name": "updated_group_name"} - response = client.put( - f"{'/v1/communities/groups'}/{new_group.id}", - data=updated_payload, - format="json", - ) - assert response.status_code == 401 - - client.credentials(HTTP_AUTHORIZATION=f"Token {access}") - response = client.put( - f"{'/v1/communities/groups'}/{new_group.id}", - data=updated_payload, - format="json", - ) - assert response.status_code == 200 - - updated_group = Group.objects.get(id=new_group.id) - assert updated_group.group_name == "updated_group_name" - - # MARK: Detail DELETE - - client.credentials() - response = client.delete(f"{'/v1/communities/groups'}/{new_group.id}") - assert response.status_code == 401 - - client.credentials(HTTP_AUTHORIZATION=f"Token {access}") - response = client.delete(f"{'/v1/communities/groups'}/{new_group.id}") - - assert response.status_code == 204 diff --git a/backend/communities/groups/tests/topic/test_group_topic.py b/backend/communities/groups/tests/topic/test_group_topic.py index f7254ee12..393924fe1 100644 --- a/backend/communities/groups/tests/topic/test_group_topic.py +++ b/backend/communities/groups/tests/topic/test_group_topic.py @@ -16,10 +16,14 @@ def test_group_topic_multiple_topics() -> None: Test multiple topics for a single group. """ group = GroupFactory() - topics = TopicFactory.create_batch(3) + + # Note: Creating a random topic runs the chance of creating the same topic twice. + topic_1 = TopicFactory.create() + topic_2 = TopicFactory.create(type="A_DIFFERENT_TOPIC") + topics = [topic_1, topic_2] group.topics.set(topics) - assert len(topics) == 3 + assert len(topics) == 2 for topic in topics: assert topic in group.topics.all() diff --git a/backend/communities/groups/views.py b/backend/communities/groups/views.py index 7f7baec9c..9610175a3 100644 --- a/backend/communities/groups/views.py +++ b/backend/communities/groups/views.py @@ -41,7 +41,7 @@ GroupSocialLinkSerializer, GroupTextSerializer, ) -from content.models import Image, Location +from content.models import Image from content.serializers import ImageSerializer from core.paginator import CustomPagination from core.permissions import IsAdminStaffCreatorOrReadOnly @@ -102,25 +102,11 @@ def post(self, request: Request) -> Response: serializer_class = self.get_serializer_class() serializer = serializer_class(data=request.data) serializer.is_valid(raise_exception=True) + # group = serializer.save(created_by=request.user) - location_dict = serializer.validated_data["location"] - location = Location.objects.create(**location_dict) - - try: - serializer.save(created_by=request.user, location=location) - logger.info( - f"Group created by user {request.user.id} with location {location.id}" - ) - - except (IntegrityError, OperationalError) as e: - logger.exception(f"Failed to create group for user {request.user.id}: {e}") - Location.objects.filter(id=location.id).delete() - return Response( - {"detail": "Failed to create group."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + "Group was created successfully.", status=status.HTTP_201_CREATED + ) # MARK: Detail API diff --git a/backend/communities/organizations/filters.py b/backend/communities/organizations/filters.py index 74e373229..6076aea81 100644 --- a/backend/communities/organizations/filters.py +++ b/backend/communities/organizations/filters.py @@ -21,7 +21,7 @@ class OrganizationFilter(django_filters.FilterSet): # type: ignore[misc] queryset=Topic.objects.all(), ) location = django_filters.CharFilter( - field_name="location__display_name", + field_name="location__address_or_name", lookup_expr="icontains", ) diff --git a/backend/communities/organizations/models.py b/backend/communities/organizations/models.py index 16814f9cb..4ddca224f 100644 --- a/backend/communities/organizations/models.py +++ b/backend/communities/organizations/models.py @@ -25,7 +25,7 @@ class Organization(models.Model): related_name="created_org", on_delete=models.CASCADE, ) - org_name = models.CharField(max_length=255) + description = models.CharField(max_length=2500, blank=True, default="") name = models.CharField(max_length=255) tagline = models.CharField(max_length=255, blank=True) icon_url = models.ForeignKey( diff --git a/backend/communities/organizations/serializers.py b/backend/communities/organizations/serializers.py index ff9ae9cc0..f113f183a 100644 --- a/backend/communities/organizations/serializers.py +++ b/backend/communities/organizations/serializers.py @@ -7,6 +7,7 @@ from typing import Any from uuid import UUID +from django.db import IntegrityError, OperationalError, transaction from rest_framework import serializers from communities.groups.serializers import GroupSerializer @@ -22,8 +23,8 @@ OrganizationTask, OrganizationText, ) -from content.models import Topic -from content.serializers import ImageSerializer, LocationSerializer +from content.models import Location, Topic +from content.serializers import ImageSerializer, LocationSerializer, TopicSerializer from events.serializers import EventSerializer logger = logging.getLogger(__name__) @@ -187,6 +188,82 @@ class Meta: # MARK: Organization +class OrganizationPOSTSerializer(serializers.Serializer[Organization]): + """ + Serializer for Organization model data on POST requests. + """ + + name = serializers.CharField(max_length=255) + tagline = serializers.CharField(max_length=255, required=False, allow_blank=True) + description = serializers.CharField(max_length=2500) + topics = TopicSerializer(many=True, required=False) + country_code = serializers.CharField(max_length=3, default="en") + city = serializers.CharField(max_length=255) + + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + """ + Validate the data being posted. + + Parameters + ---------- + data : dict[str, Any] + The data to be posted. + + Returns + ------- + dict[str, Any] + The data post validation. + """ + return data + + def create(self, validated_data: dict[str, Any]) -> Organization: + """ + Create an organization via a post operation. + + Parameters + ---------- + validated_data : dict[str, Any] + Data to be used in the creation of an organization. + + Returns + ------- + Organization + The organization object that was created in the database. + """ + with transaction.atomic(): + city = validated_data.pop("city") + country_code = validated_data.pop("country_code") + description = validated_data.pop("description", "") + # iso = validated_data.pop("iso") + + location_data = { + "city": city, + "country_code": country_code, + "lat": "", + "lon": "", + } + location = Location.objects.create(**location_data) + + try: + org = Organization.objects.create(location=location, **validated_data) + + org_text = OrganizationText.objects.create( + org=org, + # iso=iso, + primary=True, + description=description, + ) + org.texts.set([org_text]) + + logger.info("Created Organization with id: %s", org.id) + + return org + + except (IntegrityError, OperationalError) as e: + location.delete() + raise e + + class OrganizationSerializer(serializers.ModelSerializer[Organization]): """ Serializer for Organization model data. diff --git a/backend/communities/organizations/tests/test_org_api.py b/backend/communities/organizations/tests/test_org_api.py deleted file mode 100644 index 7b4cb8ae9..000000000 --- a/backend/communities/organizations/tests/test_org_api.py +++ /dev/null @@ -1,226 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -from pathlib import Path -from typing import Any, TypedDict - -import pytest -from django.conf import settings -from django.core.management import call_command -from rest_framework.test import APIClient - -from authentication.factories import UserFactory -from authentication.models import UserModel -from communities.organizations.factories import OrganizationFactory -from communities.organizations.models import Organization, OrganizationApplication -from content.factories import EntityLocationFactory - - -class UserDict(TypedDict): - user: UserModel - plaintext_password: str - - -def create_user(password: str) -> UserDict: - """ - Create a user and return the user and password. - """ - user = UserFactory.create(plaintext_password=password, is_confirmed=True) - return {"user": user, "plaintext_password": password} - - -def login_user(user_data: UserDict) -> dict: - """ - Log in a user and return the user and token. - """ - client = APIClient() - - response = client.post( - "/v1/auth/sign_in", - { - "username": user_data["user"].username, - "password": user_data["plaintext_password"], - }, - ) - assert response.status_code == 200 - return {"user": user_data["user"], "access": response.data["access"]} - - -@pytest.fixture(scope="session") -def status_types(django_db_setup, django_db_blocker) -> None: - """ - Load the status_types fixture into the test database. - """ - with django_db_blocker.unblock(): - fixture_path = Path(settings.BASE_DIR) / "fixtures" / "status_types.json" - call_command("loaddata", str(fixture_path), verbosity=2) - - -@pytest.fixture -def new_user() -> UserDict: - return create_user("Activist@123!?") - - -@pytest.fixture -def created_by_user() -> UserModel: - """ - Create a user and return the user object. - """ - return create_user("Creator@123!?")["user"] - - -@pytest.fixture -def logged_in_user(new_user) -> dict[Any, Any]: - """ - Create a user and log in the user. - """ - return login_user(new_user) - - -@pytest.fixture -def logged_in_created_by_user(created_by_user) -> dict: - return login_user({"user": created_by_user, "plaintext_password": "Creator@123!?"}) - - -@pytest.mark.django_db -def test_OrganizationAPIView(logged_in_user, status_types) -> None: - """ - Test OrganizationAPIView - - # GET request - - 1. Verify the number of organizations in the database - 2. Test the list view endpoint - 3. Check if the response contains the pagination keys - 4. Test if query_param page_size is working properly - 5. Verify the number of organizations in the response matches the page_size - 6. Check the pagination links in the response - - # POST request - - 1. Create a new organization with a valid payload - 2. Verify the response status code is 201 (Created) - """ - client = APIClient() - - number_of_orgs = 10 - test_page_size = 1 - - # MARK: List GET - - OrganizationFactory.create_batch(number_of_orgs) - assert Organization.objects.count() == number_of_orgs - - response = client.get("/v1/communities/organizations") - assert response.status_code == 200 - - pagination_keys = ["count", "next", "previous", "results"] - assert all(key in response.data for key in pagination_keys) - - response = client.get( - f"{'/v1/communities/organizations'}?pageSize={test_page_size}" - ) - assert response.status_code == 200 - - assert len(response.data["results"]) == test_page_size - assert response.data["previous"] is None - assert response.data["next"] is not None - - # MARK: List POST - - # Not Authenticated. - response = client.post("/v1/communities/organizations") - assert response.status_code == 401 - - # Authenticated and successful. - new_org = OrganizationFactory.build(org_name="new_org", terms_checked=True) - location = EntityLocationFactory.build() - access = logged_in_user["access"] - - payload = { - "location": { - "lat": location.lat, - "lon": location.lon, - "bbox": location.bbox, - "display_name": location.display_name, - }, - "org_name": new_org.org_name, - "name": new_org.name, - "tagline": new_org.tagline, - "terms_checked": new_org.terms_checked, - "is_high_risk": new_org.is_high_risk, - } - - client.credentials(HTTP_AUTHORIZATION=f"Token {access}") - response = client.post("/v1/communities/organizations", data=payload, format="json") - - assert response.status_code == 201 - assert Organization.objects.filter(org_name=new_org.org_name).exists() - assert OrganizationApplication.objects.filter( - org__org_name=new_org.org_name - ).exists() - - -@pytest.mark.django_db -def test_organizationDetailAPIView(logged_in_user, logged_in_created_by_user) -> None: - """ - Test OrganizationDetailAPIView - - # GET request - - 1. Create a new organization and verify it exists in the database. - 2. Test the detail view endpoint for the organization. - 3. Verify the response status code is 200 (OK). - 4. Ensure the response data matches the organization data. - - # PUT request - - 1. Attempt to update the organization with a user that is not the created_by user and verify it fails. - 2. Update the organization with the created_by user and verify the changes are saved. - - # DELETE request - - 1. Delete the organization with the created_by user and verify it is removed from the database. - """ - client = APIClient() - - created_by_user, access = logged_in_created_by_user.values() - - new_org = OrganizationFactory.create(created_by=created_by_user) - assert Organization.objects.filter(org_name=new_org.org_name).exists() - - # MARK: Detail GET - - response = client.get(f"{'/v1/communities/organizations'}/{new_org.id}") - assert response.status_code == 200 - assert response.data["org_name"] == new_org.org_name - - # MARK: Detail PUT - - updated_payload = {"org_name": "updated_org_name"} - response = client.put( - f"{'/v1/communities/organizations'}/{new_org.id}", - data=updated_payload, - format="json", - ) - assert response.status_code == 401 - - client.credentials(HTTP_AUTHORIZATION=f"Token {access}") - response = client.put( - f"{'/v1/communities/organizations'}/{new_org.id}", - data=updated_payload, - format="json", - ) - assert response.status_code == 200 - - updated_org = Organization.objects.get(id=new_org.id) - assert updated_org.org_name == "updated_org_name" - - # MARK: Detail DELETE - - client.credentials() - response = client.delete(f"{'/v1/communities/organizations'}/{new_org.id}") - assert response.status_code == 401 - - client.credentials(HTTP_AUTHORIZATION=f"Token {access}") - response = client.delete(f"{'/v1/communities/organizations'}/{new_org.id}") - - assert response.status_code == 204 diff --git a/backend/communities/organizations/tests/topic/test_org_topic.py b/backend/communities/organizations/tests/topic/test_org_topic.py index 4e19fb243..5f90a316c 100644 --- a/backend/communities/organizations/tests/topic/test_org_topic.py +++ b/backend/communities/organizations/tests/topic/test_org_topic.py @@ -16,10 +16,14 @@ def test_org_topic_multiple_topics() -> None: Test multiple topics for a single organization. """ org = OrganizationFactory() - topics = TopicFactory.create_batch(3) + + # Note: Creating a random topic runs the chance of creating the same topic twice. + topic_1 = TopicFactory.create() + topic_2 = TopicFactory.create(type="A_DIFFERENT_TOPIC") + topics = [topic_1, topic_2] org.topics.set(topics) - assert len(topics) == 3 + assert len(topics) == 2 for topic in topics: assert topic in org.topics.all() diff --git a/backend/communities/organizations/views.py b/backend/communities/organizations/views.py index 1ef6abb2c..6dd436b9d 100644 --- a/backend/communities/organizations/views.py +++ b/backend/communities/organizations/views.py @@ -5,8 +5,10 @@ """ import logging +from typing import Type from uuid import UUID +from django.contrib.auth.models import AnonymousUser from django.db.utils import IntegrityError, OperationalError from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend @@ -23,6 +25,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from authentication.models import UserModel from communities.models import StatusType from communities.organizations.filters import OrganizationFilter from communities.organizations.models import ( @@ -37,12 +40,13 @@ from communities.organizations.serializers import ( OrganizationFaqSerializer, OrganizationFlagSerializer, + OrganizationPOSTSerializer, OrganizationResourceSerializer, OrganizationSerializer, OrganizationSocialLinkSerializer, OrganizationTextSerializer, ) -from content.models import Image, Location +from content.models import Image from content.serializers import ImageSerializer from core.paginator import CustomPagination from core.permissions import IsAdminStaffCreatorOrReadOnly @@ -54,7 +58,6 @@ class OrganizationAPIView(GenericAPIView[Organization]): queryset = Organization.objects.all().order_by("id") - serializer_class = OrganizationSerializer pagination_class = CustomPagination permission_classes = [IsAuthenticatedOrReadOnly] filterset_class = OrganizationFilter @@ -74,6 +77,13 @@ def get(self, request: Request) -> Response: serializer = self.get_serializer(self.queryset, many=True) return Response(serializer.data) + def get_serializer_class( + self, + ) -> Type[OrganizationPOSTSerializer | OrganizationSerializer]: + if self.request.method == "POST": + return OrganizationPOSTSerializer + return OrganizationSerializer + @extend_schema( summary="Create a new organization", request=OrganizationSerializer, @@ -96,29 +106,17 @@ def post(self, request: Request) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - location_dict = serializer.validated_data["location"] - location = Location.objects.create(**location_dict) - serializer.validated_data["location"] = location - # Location post-cleanup if the organization creation fails. # This is necessary because of a not null constraint on the location field. - try: - org = serializer.save(created_by=request.user) - logger.info(f"Organization created successfully: {org.id}") - except (IntegrityError, OperationalError): - logger.exception( - f"Failed to create organization for user {request.user.id}" - ) - Location.objects.filter(id=location.id).delete() - return Response( - {"detail": "Failed to create organization."}, - status=status.HTTP_400_BAD_REQUEST, - ) + org = serializer.save(created_by=request.user) + logger.info(f"Organization created successfully: {org.id}") org.application.create() - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + "Successfully created organization", status=status.HTTP_201_CREATED + ) # MARK: Get Organization by User ID @@ -150,7 +148,7 @@ class OrganizationByUserAPIView(GenericAPIView[Organization]): }, ) def get(self, request: Request, user_id: None | UUID = None) -> Response: - user = request.user + user: UserModel | AnonymousUser = request.user if user_id is None: return Response( {"detail": "User ID is required."}, @@ -175,7 +173,9 @@ def get(self, request: Request, user_id: None | UUID = None) -> Response: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) - orgs = Organization.objects.filter(created_by__user__id=user_id) + orgs = Organization.objects.filter(created_by__user__id=user_id).order_by( + "creation_date" + ) serializer = OrganizationSerializer(orgs, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/content/factories.py b/backend/content/factories.py index 42cb7a9d6..5526c8e31 100644 --- a/backend/content/factories.py +++ b/backend/content/factories.py @@ -5,6 +5,7 @@ # mypy: ignore-errors import datetime +import json import random from uuid import uuid4 @@ -22,6 +23,11 @@ Topic, ) +with open("fixtures/topics.json") as f: + topics_dict = json.load(f) + +topic_types = [t["fields"]["type"] for t in topics_dict] + # MARK: Community Loc @@ -88,7 +94,7 @@ def location(self, create, extracted, **kwargs): self.lat = random_locations[self.location_idx][0] self.lon = random_locations[self.location_idx][1] self.bbox = random_locations[self.location_idx][2] - self.display_name = random_locations[self.location_idx][3] + self.address_or_name = random_locations[self.location_idx][3] # MARK: Event Loc @@ -157,7 +163,7 @@ def location(self, create, extracted, **kwargs): self.lat = random_locations[self.location_idx][0] self.lon = random_locations[self.location_idx][1] self.bbox = random_locations[self.location_idx][2] - self.display_name = random_locations[self.location_idx][3] + self.address_or_name = random_locations[self.location_idx][3] # MARK: Discussion @@ -314,8 +320,8 @@ class TopicFactory(factory.django.DjangoModelFactory): class Meta: model = Topic - type = factory.Faker("word") - active = factory.Faker("boolean") + type = random.choice(topic_types) + active = True creation_date = factory.LazyFunction( lambda: datetime.datetime.now(tz=datetime.timezone.utc) ) diff --git a/backend/content/models.py b/backend/content/models.py index b26b412c0..4d32996b3 100644 --- a/backend/content/models.py +++ b/backend/content/models.py @@ -173,12 +173,14 @@ class Location(models.Model): """ id = models.UUIDField(primary_key=True, default=uuid4, editable=False) - lat = models.CharField(max_length=24) - lon = models.CharField(max_length=24) + address_or_name = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=255) + country_code = models.CharField(max_length=3, choices=ISO_CHOICES) + lat = models.CharField(max_length=24, blank=True) + lon = models.CharField(max_length=24, blank=True) bbox = ArrayField( base_field=models.CharField(max_length=24), size=4, blank=True, null=True ) - display_name = models.CharField(max_length=255) def __str__(self) -> str: return str(self.id) diff --git a/backend/content/tests/faq/test_faq.py b/backend/content/tests/faq/test_faq.py index c99d16e16..0d70b6102 100644 --- a/backend/content/tests/faq/test_faq.py +++ b/backend/content/tests/faq/test_faq.py @@ -112,7 +112,6 @@ def test_validate_group_with_group_instance(): group = Group.objects.create( org=org, created_by=user, - group_name=fake.company(), name=fake.company(), tagline=fake.catch_phrase(), location=location, @@ -138,7 +137,6 @@ def test_validate_group_with_valid_uuid(): group = Group.objects.create( org=org, created_by=user, - group_name=fake.company(), name=fake.company(), tagline=fake.catch_phrase(), location=location, diff --git a/backend/content/tests/image/test_image_serializer.py b/backend/content/tests/image/test_image_serializer.py index e93af1006..8aced21e1 100644 --- a/backend/content/tests/image/test_image_serializer.py +++ b/backend/content/tests/image/test_image_serializer.py @@ -5,14 +5,12 @@ import io import logging -from datetime import timedelta from unittest.mock import patch from uuid import uuid4 import pytest from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile -from django.utils import timezone from PIL import Image as TestImage from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -180,12 +178,10 @@ def test_image_icon_serializer_create_event(caplog): # Create event with ALL required fields. event = Event.objects.create( name="Test Event", - start_time=timezone.now(), - end_time=timezone.now() + timedelta(hours=1), created_by=user, - orgs=org, physical_location=location, # associate with location ) + event.orgs.set([org]) # Create a valid image. img = TestImage.new("RGB", (100, 100), color="red") diff --git a/backend/content/tests/location/test_location_str_methods.py b/backend/content/tests/location/test_location_str_methods.py index fbb81bc46..57c4601a7 100644 --- a/backend/content/tests/location/test_location_str_methods.py +++ b/backend/content/tests/location/test_location_str_methods.py @@ -9,7 +9,5 @@ def test_location_str_method(): Test the __str__ method of the Location model. """ location_id = uuid4() - location = Location( - id=location_id, lat="40.7128", lon="-74.0060", display_name="New York City" - ) + location = Location(id=location_id, lat="40.7128", lon="-74.0060") assert str(location) == f"{location_id}" diff --git a/backend/content/tests/social_link/test_social_link.py b/backend/content/tests/social_link/test_social_link.py index 64aac796c..6365e4897 100644 --- a/backend/content/tests/social_link/test_social_link.py +++ b/backend/content/tests/social_link/test_social_link.py @@ -160,7 +160,6 @@ def test_validate_group_with_group_instance(): group = Group.objects.create( org=org, created_by=user, - group_name=fake.company(), name=fake.company(), tagline=fake.catch_phrase(), location=location, @@ -186,7 +185,6 @@ def test_validate_group_with_valid_uuid(): group = Group.objects.create( org=org, created_by=user, - group_name=fake.company(), name=fake.company(), tagline=fake.catch_phrase(), location=location, diff --git a/backend/core/management/commands/entity_data_to_assign.json b/backend/core/management/commands/entity_data_to_assign.json index f0fc94cc0..e6df85be1 100644 --- a/backend/core/management/commands/entity_data_to_assign.json +++ b/backend/core/management/commands/entity_data_to_assign.json @@ -1,7 +1,6 @@ { "organizations": [ { - "org_name": "activist", "name": "activist", "tagline": "An open-source activism platform", "texts": { diff --git a/backend/core/management/commands/populate_db.py b/backend/core/management/commands/populate_db.py index 3ff053116..2cf8b42f3 100644 --- a/backend/core/management/commands/populate_db.py +++ b/backend/core/management/commands/populate_db.py @@ -120,7 +120,7 @@ def handle(self, *args: str, **options: Unpack[Options]) -> None: # MARK: Populate Data try: - users = [ + users: list[UserModel] = [ UserFactory(username=f"activist_{i}", name=f"Activist {i}") for i in range(num_users) ] diff --git a/backend/core/management/commands/populate_db_utils/populate_org_groups.py b/backend/core/management/commands/populate_db_utils/populate_org_groups.py index 0f3fedf42..fa5559941 100644 --- a/backend/core/management/commands/populate_db_utils/populate_org_groups.py +++ b/backend/core/management/commands/populate_db_utils/populate_org_groups.py @@ -79,16 +79,10 @@ def create_org_groups( if (assigned_groups and g < len(assigned_groups)) else None ) - group_id = ( spec.get("group_name") if spec and "group_name" in spec - else f"{user_org.org_name}:g{g}" - ) - group_name = ( - spec.get("name") - if spec and "name" in spec - else f"{user_topic_name} Group {g}" + else f"{user_org.name}:g{g}" ) tagline = ( spec.get("tagline") @@ -98,8 +92,7 @@ def create_org_groups( user_org_group = GroupFactory( created_by=user, - group_name=group_id, - name=group_name, + name=group_id, org=user_org, tagline=tagline, ) diff --git a/backend/core/management/commands/populate_db_utils/populate_orgs.py b/backend/core/management/commands/populate_db_utils/populate_orgs.py index 3b8e9d23a..6a9448119 100644 --- a/backend/core/management/commands/populate_db_utils/populate_orgs.py +++ b/backend/core/management/commands/populate_db_utils/populate_orgs.py @@ -85,16 +85,13 @@ def create_organization( # Basic fields with fallbacks. org_id = ( - assigned.get("org_name") - or f"organization_{user.username}_o{per_user_org_index}" + assigned.get("name") or f"organization_{user.username}_o{per_user_org_index}" ) - org_name = assigned.get("name") or f"{user_topic_name} Organization" tagline = assigned.get("tagline") or f"Fighting for {user_topic_name.lower()}" user_org = OrganizationFactory( created_by=user, - org_name=org_id, - name=org_name, + name=org_id, tagline=tagline, ) user_org.topics.set([user_topic]) diff --git a/backend/events/admin.py b/backend/events/admin.py index 1239a1999..d1888c5bf 100644 --- a/backend/events/admin.py +++ b/backend/events/admin.py @@ -30,7 +30,7 @@ class EventAdminForm(ModelForm): # type: ignore[type-arg] def clean(self) -> dict[str, Any]: """ - Validate and normalize Event admin form data based on the `setting` field. + Validate and normalize Event admin form data based on the `location_type` field. Returns ------- @@ -38,11 +38,11 @@ def clean(self) -> dict[str, Any]: Validated and normalized form data. """ cleaned_data: dict[str, Any] = super().clean() or {} - setting = cleaned_data.get("setting") + location_type = cleaned_data.get("location_type") online_location_link = cleaned_data.get("online_location_link") physical_location = cleaned_data.get("physical_location") - if setting == "online": + if location_type == "online": if not online_location_link: raise ValidationError( { @@ -52,7 +52,7 @@ def clean(self) -> dict[str, Any]: cleaned_data["physical_location"] = None - elif setting == "physical": + elif location_type == "physical": if not physical_location: raise ValidationError( { diff --git a/backend/events/factories.py b/backend/events/factories.py index 5393a77d2..9b79b8cab 100644 --- a/backend/events/factories.py +++ b/backend/events/factories.py @@ -6,6 +6,7 @@ # mypy: ignore-errors import datetime import random +from collections.abc import Iterable import factory @@ -18,6 +19,7 @@ EventResource, EventSocialLink, EventText, + EventTime, Format, Role, ) @@ -33,9 +35,6 @@ class EventFactory(factory.django.DjangoModelFactory): class Meta: model = Event - orgs = factory.SubFactory("communities.organizations.factories.OrganizationFactory") - # Note: Events need organizations but do not need groups. - # groups = factory.SubFactory("communities.groups.factories.GroupFactory") created_by = factory.SubFactory("authentication.factories.UserFactory") name = factory.Faker("word") tagline = factory.Faker("word") @@ -43,38 +42,6 @@ class Meta: online_location_link = factory.Faker("url") physical_location = factory.SubFactory("content.factories.EventLocationFactory") is_private = factory.Faker("boolean") - start_time = factory.LazyFunction( - lambda: datetime.datetime.now(tz=datetime.timezone.utc) - + datetime.timedelta( - # Weighted distribution: - # - 30% within 1 day - # - 30% within 7 days - # - 20% within 30 days - # - 10% far future (30-90 days) - # - 10% past events - days=random.choices( - [ - random.randint(0, 1), # today or tomorrow - random.randint(2, 7), # this week - random.randint(8, 30), # this month - random.randint(31, 90), # far future - random.randint(-30, -1), # past events - ], - weights=[30, 30, 20, 10, 10], - k=1, - )[0], - # Events between 8 AM and 8 PM. - hours=random.randint(8, 20), - # Round to 15-minute intervals. - minutes=random.choice([0, 15, 30, 45]), - ) - ) - end_time = factory.LazyAttribute( - lambda obj: obj.start_time - + datetime.timedelta( - hours=random.randint(1, 8) # events last 1-8 hours - ) - ) creation_date = factory.LazyFunction( lambda: datetime.datetime.now(tz=datetime.timezone.utc) ) @@ -85,7 +52,147 @@ class Meta: + datetime.timedelta(days=30), ] ) - setting = random.choice(["online", "physical"]) + location_type = random.choice(["online", "physical"]) + + @factory.post_generation + def orgs(self, create, extracted, **kwargs): # type: ignore[override] + """ + Attach organizations via ManyToMany operations. + + Parameters + ---------- + create : bool + Whether organizations should be created. + + extracted : [OrganizationFactory] + Extracted organization factories. + + **kwargs : dict + Extra keyword arguments. + """ + if not create: + return + + if extracted is None: + from communities.organizations.factories import OrganizationFactory + + organizations = [OrganizationFactory()] + + elif isinstance(extracted, Iterable) and not isinstance( + extracted, (str, bytes) + ): + organizations = list(extracted) + + else: + organizations = [extracted] + + self.orgs.set(organizations) + + @factory.post_generation + def groups(self, create, extracted, **kwargs): # type: ignore[override] + """ + Attach optional groups via ManyToMany operations. + + Parameters + ---------- + create : bool + Whether groups should be created. + + extracted : [GroupFactory] + Extracted group factories. + + **kwargs : dict + Extra keyword arguments. + """ + if not create or extracted is None: + return + + if isinstance(extracted, Iterable) and not isinstance(extracted, (str, bytes)): + groups = list(extracted) + + else: + groups = [extracted] + + self.groups.set(groups) + + @factory.post_generation + def times(self, create, extracted, **kwargs): # type: ignore[override] + """ + Attach event times via ManyToMany operations. + + Parameters + ---------- + create : bool + Whether event times should be created. + + extracted : [EventTimeFactory] + Extracted event time factories. + + **kwargs : dict + Extra keyword arguments. + """ + if not create: + return + + if extracted is None: + # Create 1-3 event times by default. + event_times = [EventTimeFactory() for _ in range(random.randint(1, 3))] + + elif isinstance(extracted, Iterable) and not isinstance( + extracted, (str, bytes) + ): + event_times = list(extracted) + + else: + event_times = [extracted] + + self.times.set(event_times) + + +# MARK: EventTime + + +class EventTimeFactory(factory.django.DjangoModelFactory): + """ + Factory for creating EventTime model instances. + """ + + class Meta: + model = EventTime + + start_time = factory.LazyFunction( + lambda: ( + datetime.datetime.now(tz=datetime.timezone.utc) + + datetime.timedelta( + # Weighted distribution: + # - 30% within 1 day + # - 30% within 7 days + # - 20% within 30 days + # - 10% far future (30-90 days) + # - 10% past events + days=random.choices( + [ + random.randint(0, 1), # today or tomorrow + random.randint(2, 7), # this week + random.randint(8, 30), # this month + random.randint(31, 90), # far future + random.randint(-30, -1), # past events + ], + weights=[30, 30, 20, 10, 10], + k=1, + )[0], + # Events between 8 AM and 8 PM. + hours=random.randint(8, 20), + # Round to 15-minute intervals. + minutes=random.choice([0, 15, 30, 45]), + ) + ) + ) + + # Events last 1-8 hours. + end_time = factory.LazyAttribute( + lambda obj: obj.start_time + datetime.timedelta(hours=random.randint(1, 8)) + ) # MARK: Role diff --git a/backend/events/filters.py b/backend/events/filters.py index b8c9aaa22..81b4b23b0 100644 --- a/backend/events/filters.py +++ b/backend/events/filters.py @@ -26,7 +26,7 @@ class EventFilters(django_filters.FilterSet): # type: ignore[misc] queryset=Topic.objects.all(), ) location = django_filters.CharFilter( - field_name="physical_location__display_name", + field_name="physical_location__address_or_name", lookup_expr="icontains", ) @@ -35,8 +35,8 @@ class EventFilters(django_filters.FilterSet): # type: ignore[misc] lookup_expr="iexact", ) - setting = django_filters.CharFilter( - field_name="setting", + location_type = django_filters.CharFilter( + field_name="location_type", lookup_expr="iexact", ) @@ -80,7 +80,9 @@ def filter_days_ahead( end = now if days_ahead_int == 0 else now + timedelta(days=days_ahead_int) - return queryset.filter(start_time__gte=now, start_time__lte=end) + return queryset.filter( + times__start_time__gte=now, times__start_time__lte=end + ).distinct() class Meta: model = Event @@ -88,7 +90,7 @@ class Meta: "name", "topics", "type", - "setting", + "location_type", "location", "days_ahead", ] diff --git a/backend/events/models.py b/backend/events/models.py index 82224e22f..249b01ba4 100644 --- a/backend/events/models.py +++ b/backend/events/models.py @@ -25,15 +25,11 @@ class Event(models.Model): related_name="created_events", on_delete=models.CASCADE, ) - orgs = models.ForeignKey( - "communities.Organization", related_name="events", on_delete=models.CASCADE + orgs = models.ManyToManyField( + "communities.Organization", related_name="events", blank=False ) - groups = models.ForeignKey( - "communities.Group", - related_name="events", - on_delete=models.CASCADE, - blank=True, - null=True, + groups = models.ManyToManyField( + "communities.Group", related_name="events", blank=True ) name = models.CharField(max_length=255) tagline = models.CharField(max_length=255, blank=True) @@ -45,18 +41,17 @@ class Event(models.Model): ("action", "Action"), ] type = models.CharField(max_length=255, choices=TYPE_CHOICES) - SETTING_CHOICES = [ + LOCATION_TYPE_CHOICES = [ ("online", "Online"), ("physical", "Physical"), ] - setting = models.CharField(max_length=255, choices=SETTING_CHOICES) + location_type = models.CharField(max_length=255, choices=LOCATION_TYPE_CHOICES) online_location_link = models.CharField(max_length=255, blank=True, null=True) physical_location = models.OneToOneField( "content.Location", on_delete=models.CASCADE, blank=True, null=True ) is_private = models.BooleanField(default=False) - start_time = models.DateTimeField() - end_time = models.DateTimeField() + times = models.ManyToManyField("events.EventTime", blank=True) terms_checked = models.BooleanField(default=False) creation_date = models.DateTimeField(auto_now_add=True) deletion_date = models.DateTimeField(blank=True, null=True) @@ -71,18 +66,6 @@ class Event(models.Model): # Explicit type annotation required for mypy compatibility with django-stubs. flags: Any = models.ManyToManyField("authentication.UserModel", through="EventFlag") - def clean(self) -> None: - """ - Validate the event data. - - Raises - ------ - ValidationError - If the start time is after the end time. - """ - if self.start_time and self.end_time and self.start_time > self.end_time: - raise ValidationError("The start time must be before the end time.") - def save(self, *args: Any, **kwargs: Any) -> None: """ Save the event instance. @@ -106,6 +89,35 @@ def __str__(self) -> str: return self.name +# MARK: Time + + +class EventTime(models.Model): + """ + Model for event times. + """ + + def clean(self) -> None: + """ + Validate the event time data. + + Raises + ------ + ValidationError + If the start time is after the end time. + """ + if self.start_time and self.end_time and self.start_time > self.end_time: + raise ValidationError("The start time must be before the end time.") + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + start_time = models.DateTimeField() + end_time = models.DateTimeField() + all_day = models.BooleanField(default=False) + + def __str__(self) -> str: + return f"{self.start_time} - {self.end_time}" + + # MARK: Attendee diff --git a/backend/events/serializers.py b/backend/events/serializers.py index caf6c9ef4..eb54b3a82 100644 --- a/backend/events/serializers.py +++ b/backend/events/serializers.py @@ -4,17 +4,25 @@ """ import logging -from datetime import datetime, timezone +import zoneinfo +from datetime import datetime, timedelta, timezone from typing import Any, Dict, Union from uuid import UUID +from django.conf import settings +from django.db import transaction from django.utils.dateparse import parse_datetime from rest_framework import serializers from communities.groups.models import Group from communities.organizations.models import Organization -from content.models import Topic -from content.serializers import FaqSerializer, ImageSerializer, LocationSerializer +from content.models import Location, Topic +from content.serializers import ( + FaqSerializer, + ImageSerializer, + LocationSerializer, + TopicSerializer, +) from events.models import ( Event, EventFaq, @@ -22,6 +30,7 @@ EventResource, EventSocialLink, EventText, + EventTime, Format, ) from utils.utils import ( @@ -127,6 +136,19 @@ def validate_event(self, value: Event | UUID | str) -> Event: return event +# Mark: Times + + +class EventTimesSerializer(serializers.ModelSerializer[EventTime]): + """ + Serializer for EventTime model data. + """ + + class Meta: + model = EventTime + fields = "__all__" + + # MARK: Social Link @@ -213,36 +235,206 @@ class Meta: # MARK: POST -class EventPOSTSerializer(serializers.ModelSerializer[Event]): +class EventPOSTLocationSerializer(serializers.Serializer[Any]): + """ + Serializer for event location during creation. + """ + + address_or_name = serializers.CharField(required=True) + city = serializers.CharField(required=True) + country_code = serializers.CharField(required=True) + lat = serializers.CharField(required=True) + lon = serializers.CharField(required=True) + bbox = serializers.ListField( + child=serializers.CharField(), required=False, allow_empty=True + ) + + +class EventPOSTTimesSerializer(serializers.Serializer[Any]): + """ + Serializer for event times during creation. + """ + + date = serializers.DateField(required=False) + all_day = serializers.BooleanField(required=True) + start_time = serializers.DateTimeField(required=False) + end_time = serializers.DateTimeField(required=False) + + +class EventPOSTSerializer(serializers.Serializer[Any]): """ Serializer for creating events with related fields. """ - texts = EventTextSerializer(write_only=True, required=False) - social_links = EventSocialLinkSerializer(write_only=True, required=False) - physical_location = LocationSerializer(write_only=True) - org_id = serializers.PrimaryKeyRelatedField( - queryset=Organization.objects.all(), source="orgs" + orgs: serializers.ListSerializer[Any] = serializers.ListSerializer( + child=serializers.UUIDField(), required=True + ) + groups: serializers.ListSerializer[Any] = serializers.ListSerializer( + child=serializers.UUIDField(), required=False + ) + iso = serializers.CharField(required=False, max_length=3, default="en") + name = serializers.CharField(required=True) + tagline = serializers.CharField(required=False, min_length=3, max_length=255) + description = serializers.CharField(required=True, min_length=1, max_length=2500) + location_type = serializers.ChoiceField( + choices=[("physical", "Physical"), ("online", "Online")], required=True ) - # group_id = serializers.PrimaryKeyRelatedField( - # queryset=Group.objects.all(), source="groups" - # ) + type = serializers.ChoiceField( + choices=[("learn", "Learn"), ("action", "Action")], required=True + ) + topics: serializers.ListSerializer[Any] = serializers.ListSerializer( + child=serializers.CharField(), required=True, max_length=255 + ) + online_location_link = serializers.URLField(required=False, allow_blank=True) + location = EventPOSTLocationSerializer(required=False) + times = EventPOSTTimesSerializer(required=True, many=True) + iso = serializers.CharField(required=False, default="en", max_length=3) - class Meta: - model = Event + def validate(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Validate event creation data. - exclude = ( - "discussions", - "formats", - "roles", - "tags", - "tasks", - "topics", - "orgs", - "created_by", - "icon_url", - "deletion_date", - ) + Parameters + ---------- + data : Dict[str, Any] + Event creation data dictionary to validate. + + Returns + ------- + Dict[str, Any] + Validated data dictionary. + + Raises + ------ + ValidationError + If validation fails for any field. + """ + orgs = data.pop("orgs") + times: list[dict[str, Any]] = data.get("times") or [] + groups = data.pop("groups", None) + topics = data.pop("topics", None) + + orgs = Organization.objects.filter(id__in=orgs) + data["orgs"] = orgs + + # Get the local timezone from Django settings. + local_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE) + + for time in times: + if time.get("all_day"): + date = time.get("date") + # For all-day events, create times in the local timezone + # so that 00:00:00 to 23:59:59 stays in that timezone. + if date is None: + raise serializers.ValidationError( + "Date must be provided for all-day events." + ) + time["start_time"] = datetime.combine( + date, datetime.min.time(), tzinfo=local_tz + ) + time["end_time"] = datetime.combine( + date, datetime.min.time(), tzinfo=local_tz + ) + timedelta(days=1, seconds=-1) + + continue + + start_time = time.get("start_time") + end_time = time.get("end_time") + + print(start_time, end_time) + + if not start_time or not end_time: + raise serializers.ValidationError( + "Both start_time and end_time are required for each event time." + ) + + if start_time >= end_time: + raise serializers.ValidationError( + "start_time must be before end_time for each event time." + ) + + if topics: + query_topics = Topic.objects.filter(type__in=topics, active=True) + + if len(query_topics) != len(topics): + raise serializers.ValidationError( + "One or more topics are invalid or inactive." + ) + + data["topics"] = query_topics + + if groups: + query_groups = Group.objects.filter(id__in=groups, org__in=orgs).distinct() + + if len(query_groups) != len(groups): + raise serializers.ValidationError("One or more groups do not exist.") + + data["groups"] = query_groups + + return data + + def create(self, validated_data: Dict[str, Any]) -> Event: + """ + Create an event from validated data. + + Parameters + ---------- + validated_data : Dict[str, Any] + Validated event creation data. + + Returns + ------- + Event + Created Event instance. + """ + created_by = validated_data.pop("created_by") + + with transaction.atomic(): + location_type = validated_data.pop("location_type", None) + location_data = validated_data.pop("location", None) + orgs_data = validated_data.pop("orgs", []) + groups_data = validated_data.pop("groups", []) + topics_data = validated_data.pop("topics", []) + times_data = validated_data.pop("times", []) + description = validated_data.pop("description", "") + iso = validated_data.pop("iso") + + if location_data and location_type == "physical": + location = Location.objects.create(**location_data) + validated_data["physical_location"] = location + + event = Event.objects.create(created_by=created_by, **validated_data) + + # Create EventText object with description. + event_text = EventText.objects.create( + event=event, + iso=iso, + primary=True, + description=description, + ) + event.texts.set([event_text]) + + # Set many-to-many relationships + if orgs_data: + event.orgs.set(orgs_data) + if groups_data: + event.groups.set(groups_data) + if topics_data: + event.topics.set(topics_data) + + if times_data: + event_times = [ + EventTime( + start_time=time_data.get("start_time"), + end_time=time_data.get("end_time"), + all_day=time_data.get("all_day", False), + ) + for time_data in times_data + ] + EventTime.objects.bulk_create(event_times) + event.times.set(event_times) + + return event # MARK: Event @@ -258,8 +450,10 @@ class EventSerializer(serializers.ModelSerializer[Event]): physical_location = LocationSerializer() resources = EventResourceSerializer(many=True, read_only=True) faq_entries = FaqSerializer(source="faqs", many=True, read_only=True) - orgs = EventOrganizationSerializer(read_only=True) - groups = EventGroupSerializer(read_only=True) + orgs = EventOrganizationSerializer(many=True, read_only=True) + groups = EventGroupSerializer(many=True, read_only=True) + topics = TopicSerializer(many=True, read_only=True) + times = EventTimesSerializer(many=True, read_only=True) icon_url = ImageSerializer(required=False) diff --git a/backend/events/tests/test_event_api.py b/backend/events/tests/test_event_api.py deleted file mode 100644 index d19dcc409..000000000 --- a/backend/events/tests/test_event_api.py +++ /dev/null @@ -1,237 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -from datetime import datetime -from typing import Any, TypedDict -from unittest.mock import patch -from uuid import uuid4 - -import pytest -from dateutil.relativedelta import relativedelta -from rest_framework.test import APIClient - -from authentication.factories import UserFactory -from authentication.models import UserModel -from communities.organizations.factories import OrganizationFactory -from content.factories import EntityLocationFactory -from events.factories import EventFactory -from events.models import Event - - -class UserDict(TypedDict): - user: UserModel - plaintext_password: str - - -def create_user(password: str) -> UserDict: - """ - Create a user. Returns the user and password. - """ - user = UserFactory.create(plaintext_password=password, is_confirmed=True) - return {"user": user, "plaintext_password": password} - - -def login_user(user_data: UserDict) -> dict[Any, Any]: - """ - Log in a user. Returns the user and token. - """ - client = APIClient() - - response = client.post( - "/v1/auth/sign_in", - { - "username": user_data["user"].username, - "password": user_data["plaintext_password"], - }, - ) - assert response.status_code == 200 - return {"user": user_data["user"], "access": response.data["access"]} - - -@pytest.fixture -def logged_in_user() -> dict[Any, Any]: - """ - Create a user and log in the user. - """ - return login_user(create_user("Activist@123!?")) - - -@pytest.mark.django_db -def test_EventListAPIView(logged_in_user) -> None: - """ - Test OrganizationAPIView - - # GET request - - 1. Verify the number of events in the database - 2. Test the list view endpoint - 3. Check if the pagination keys are present - 4. Test if query_param page_size is working properly - 5. Verify the number of events in the response matches the page_size - 6. Check the pagination links in the response - - # POST request - - 1. Create a new organization with a valid payload - 2. Verify the response status code is 201 (Created) - """ - client = APIClient() - - number_of_events = 10 - test_page_size = 1 - - # MARK: List GET - - EventFactory.create_batch(number_of_events) - assert Event.objects.count() == number_of_events - - response = client.get("/v1/events/events") - assert response.status_code == 200 - - pagination_key = ["count", "next", "previous", "results"] - assert all(key in response.data for key in pagination_key) - - response = client.get(f"{'/v1/events/events'}?pageSize={test_page_size}") - assert response.status_code == 200 - - assert len(response.data["results"]) == test_page_size - assert response.data["previous"] is None - assert response.data["next"] is not None - - # MARK: List POST - - # Not Authenticated. - response = client.post("/v1/events/events") - assert response.status_code == 401 - - # Authenticated and successful. - org = OrganizationFactory.create(org_name="test_org", terms_checked=True) - new_event = EventFactory.build(name="new_event", terms_checked=True) - location = EntityLocationFactory.build() - token = logged_in_user["access"] - - payload = { - "name": new_event.name, - "org_id": org.id, - "tagline": new_event.tagline, - "physical_location": { - "lat": location.lat, - "lon": location.lon, - "bbox": location.bbox, - "display_name": location.display_name, - }, - "type": new_event.type, - "start_time": new_event.start_time, - "end_time": new_event.end_time, - "terms_checked": new_event.terms_checked, - "setting": "physical", - } - - client.credentials(HTTP_AUTHORIZATION=f"Token {token}") - response = client.post("/v1/events/events", data=payload, format="json") - - assert response.status_code == 201 - assert Event.objects.filter(name=new_event.name).exists() - - # Incorrect time order. - new_event.start_time = "2025-10-20T18:00:00Z" - new_event.end_time = "2025-10-20T06:00:00Z" - payload["start_time"] = new_event.start_time - payload["end_time"] = new_event.end_time - response = client.post("/v1/events/events", data=payload, format="json") - assert response.status_code == 400 - assert "start time must be before the end time" in str(response.data).lower() - - -@pytest.mark.django_db -def test_EventListAPIView_no_pagination(authenticated_client) -> None: - client, user = authenticated_client - - with patch("events.views.EventAPIView.paginate_queryset") as mock_paginate: - mock_paginate.return_value = None - - response = client.get(path="/v1/events/events") - assert response.status_code == 200 - - # Verify that paginate_queryset was called. - mock_paginate.assert_called_once() - - -@pytest.mark.django_db -def test_EventDetailAPIView(logged_in_user) -> None: # type: ignore[no-untyped-def] - client = APIClient() - - created_by_user, access = logged_in_user.values() - - new_event = EventFactory.create(created_by=created_by_user) - assert Event.objects.filter(name=new_event.name).exists() - - # MARK: Detail GET - - response = client.get(f"{'/v1/events/events'}/{new_event.id}") - - assert response.status_code == 200 - assert response.data["name"] == new_event.name - - # MARK: Detail PUT - - start_date = datetime.now() + relativedelta(years=2) - end_dt = datetime.now() + relativedelta(years=2, days=1) - - payload = { - "name": "new_event", - "start_time": start_date, - "end_time": end_dt, - "terms_checked": True, - } - response = client.put( - f"{'/v1/events/events'}/{new_event.id}", data=payload, format="json" - ) - - assert response.status_code == 401 - - client.credentials(HTTP_AUTHORIZATION=f"Token {access}") - response = client.put( - f"{'/v1/events/events'}/{new_event.id}", data=payload, format="json" - ) - - assert response.status_code == 200 - assert payload["name"] == Event.objects.get(id=new_event.id).name - - # MARK: Detail DELETE - - client.credentials() - response = client.delete(f"{'/v1/events/events'}/{new_event.id}") - assert response.status_code == 401 - - client.credentials(HTTP_AUTHORIZATION=f"Token {access}") - response = client.delete(f"{'/v1/events/events'}/{new_event.id}") - - assert response.status_code == 204 - assert not Event.objects.filter(id=new_event.id).exists() - - -@pytest.mark.django_db -def test_EventDetailAPIView_failure(authenticated_client) -> None: - client, user = authenticated_client - uuid = uuid4() - with patch("events.views.EventDetailAPIView.queryset.get") as mock_queryset: - mock_queryset.side_effect = Event.DoesNotExist - - response = client.get(path=f"{'/v1/events/events'}/{uuid}") - - assert response.status_code == 404 - assert response.data["detail"] == "Event Not Found." - - # Verify that paginate_queryset was called. - mock_queryset.assert_called_once() - - # verify for PUT method. - response = client.put(path=f"{'/v1/events/events'}/{uuid}") - - assert response.status_code == 404 - assert response.data["detail"] == "Event Not Found." - - # verify for DELETE method. - response = client.delete(path=f"{'/v1/events/events'}/{uuid}") - - assert response.status_code == 404 - assert response.data["detail"] == "Event Not Found." diff --git a/backend/events/tests/test_event_create.py b/backend/events/tests/test_event_create.py new file mode 100644 index 000000000..306715a7f --- /dev/null +++ b/backend/events/tests/test_event_create.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +import pytest + +from communities.organizations.factories import OrganizationFactory +from communities.organizations.models import Organization +from content.factories import TopicFactory +from content.models import Topic + +pytestmark = pytest.mark.django_db + + +def test_event_create(authenticated_client) -> None: + """ + Test event creation. + """ + client, _ = authenticated_client + + org: Organization = OrganizationFactory.create() + topic: Topic = TopicFactory.create() + topic.active = True + + event_data = { + "name": "Community Cleanup", + "tagline": "Join us to clean up the park!", + "description": "A community event to clean up the local park.", + "type": "action", + "location_type": "physical", + "location": { + "address_or_name": "123 Park St", + "city": "Greenville", + "country_code": "en", + "lat": "34.0522", + "lon": "-118.2437", + }, + "topics": [topic.type], + "orgs": [str(org.id)], + "times": [ + { + "all_day": False, + "start_time": "2024-07-15T09:00:00Z", + "end_time": "2024-07-15T12:00:00Z", + } + ], + } + + response = client.post( + path="/v1/events/events", + data=event_data, + format="json", + ) + + assert response.status_code == 201 diff --git a/backend/events/tests/test_event_filters.py b/backend/events/tests/test_event_filters.py index 871809806..9ce6594fc 100644 --- a/backend/events/tests/test_event_filters.py +++ b/backend/events/tests/test_event_filters.py @@ -10,7 +10,7 @@ import pytest from rest_framework.test import APIClient -from events.factories import EventFactory +from events.factories import EventFactory, EventTimeFactory pytestmark = pytest.mark.django_db @@ -27,20 +27,32 @@ def test_days_ahead_filters_events_within_window(mock_now) -> None: # Within the 10 day window. event_in_window = EventFactory.create( - start_time=FIXED_NOW + timedelta(days=5), - end_time=FIXED_NOW + timedelta(days=5, hours=2), + times=[ + EventTimeFactory( + start_time=FIXED_NOW + timedelta(days=5), + end_time=FIXED_NOW + timedelta(days=5, hours=2), + ) + ] ) # Before now (past). event_in_past = EventFactory.create( - start_time=FIXED_NOW - timedelta(days=1), - end_time=FIXED_NOW - timedelta(hours=20), + times=[ + EventTimeFactory.create( + start_time=FIXED_NOW - timedelta(days=1), + end_time=FIXED_NOW - timedelta(hours=20), + ) + ] ) # After the window. event_after_window = EventFactory.create( - start_time=FIXED_NOW + timedelta(days=15), - end_time=FIXED_NOW + timedelta(days=15, hours=2), + times=[ + EventTimeFactory( + start_time=FIXED_NOW + timedelta(days=15), + end_time=FIXED_NOW + timedelta(days=15, hours=2), + ) + ] ) response = client.get(f"{EVENTS_URL}?days_ahead=10") @@ -63,14 +75,22 @@ def test_days_ahead_rolling_24h_window(mock_now) -> None: # Inside 1-day window (now + 23 hours). event_inside = EventFactory.create( - start_time=FIXED_NOW + timedelta(hours=23), - end_time=FIXED_NOW + timedelta(hours=25), + times=[ + EventTimeFactory( + start_time=FIXED_NOW + timedelta(hours=21), + end_time=FIXED_NOW + timedelta(hours=23), + ) + ] ) # Just outside 1-day window (now + 25 hours). - event_outside = EventFactory.create( - start_time=FIXED_NOW + timedelta(hours=25), - end_time=FIXED_NOW + timedelta(hours=27), + event_outside = EventFactory( + times=[ + EventTimeFactory( + start_time=FIXED_NOW + timedelta(hours=25), + end_time=FIXED_NOW + timedelta(hours=27), + ) + ] ) response = client.get(f"{EVENTS_URL}?days_ahead=1") @@ -83,45 +103,61 @@ def test_days_ahead_rolling_24h_window(mock_now) -> None: @patch("django.utils.timezone.now", return_value=FIXED_NOW) -def test_days_ahead_with_type_and_setting_combination(mock_now) -> None: +def test_days_ahead_with_type_and_location_type_combination(mock_now) -> None: """ - days_ahead works in combination with other filters (type, setting). + days_ahead works in combination with other filters (type, location_type). """ client = APIClient() event_match = EventFactory.create( type="learn", - setting="online", - start_time=FIXED_NOW + timedelta(days=3), - end_time=FIXED_NOW + timedelta(days=3, hours=2), + location_type="online", + times=[ + EventTimeFactory( + start_time=FIXED_NOW + timedelta(days=3), + end_time=FIXED_NOW + timedelta(days=3, hours=2), + ) + ], ) # Wrong type. - EventFactory.create( + EventFactory( type="action", - setting="online", - start_time=FIXED_NOW + timedelta(days=3), - end_time=FIXED_NOW + timedelta(days=3, hours=2), + location_type="physical", + times=[ + EventTimeFactory( + start_time=FIXED_NOW + timedelta(days=3), + end_time=FIXED_NOW + timedelta(days=3, hours=2), + ) + ], ) - # Wrong setting. - EventFactory.create( + # Wrong location_type. + EventFactory( type="learn", - setting="physical", - start_time=FIXED_NOW + timedelta(days=3), - end_time=FIXED_NOW + timedelta(days=3, hours=2), + location_type="physical", + times=[ + EventTimeFactory( + start_time=FIXED_NOW + timedelta(days=3), + end_time=FIXED_NOW + timedelta(days=3, hours=2), + ) + ], ) # Outside days_ahead window. - EventFactory.create( + EventFactory( type="learn", - setting="online", - start_time=FIXED_NOW + timedelta(days=20), - end_time=FIXED_NOW + timedelta(days=20, hours=2), + location_type="online", + times=[ + EventTimeFactory( + start_time=FIXED_NOW + timedelta(days=20), + end_time=FIXED_NOW + timedelta(days=20, hours=2), + ) + ], ) response = client.get( - f"{EVENTS_URL}?days_ahead=10&type=learn&setting=online", + f"{EVENTS_URL}?days_ahead=10&type=learn&location_type=online", ) assert response.status_code == 200 @@ -143,16 +179,17 @@ def test_days_ahead_ignores_non_positive_values(mock_now) -> None: client = APIClient() # Event exactly at now. - event_now = EventFactory.create( - start_time=FIXED_NOW, - end_time=FIXED_NOW + timedelta(hours=2), + event_now = EventFactory( + times=[ + EventTimeFactory( + start_time=FIXED_NOW, + end_time=FIXED_NOW + timedelta(hours=2), + ) + ] ) # Event in future. - event_future = EventFactory.create( - start_time=FIXED_NOW + timedelta(days=1), - end_time=FIXED_NOW + timedelta(days=1, hours=2), - ) + event_future = EventFactory.create() response = client.get(f"{EVENTS_URL}?days_ahead=0") assert response.status_code == 200 diff --git a/backend/events/tests/test_event_update.py b/backend/events/tests/test_event_update.py index 16ba526b8..919f83f8d 100644 --- a/backend/events/tests/test_event_update.py +++ b/backend/events/tests/test_event_update.py @@ -23,8 +23,6 @@ def test_event_update(authenticated_client) -> None: data={ "name": "test_name", "type": "test_type", - "startTime": event.start_time, - "endTime": event.end_time, }, content_type="application/json", ) diff --git a/backend/events/views.py b/backend/events/views.py index 4202feeb0..a1e2fe516 100644 --- a/backend/events/views.py +++ b/backend/events/views.py @@ -26,7 +26,6 @@ from rest_framework.response import Response from rest_framework.views import APIView -from content.models import Location from core.paginator import CustomPagination from core.permissions import IsAdminStaffCreatorOrReadOnly from events.filters import EventFilters @@ -93,39 +92,27 @@ def get(self, request: Request) -> Response: ) def post(self, request: Request) -> Response: serializer_class = self.get_serializer_class() - serializer: EventPOSTSerializer | EventSerializer = serializer_class( - data=request.data - ) + serializer = serializer_class(data=request.data) serializer.is_valid(raise_exception=True) - location_data = serializer.validated_data["physical_location"] - location = Location.objects.create(**location_data) + validated_data = serializer.validated_data + validated_data["created_by"] = request.user try: - serializer.save(created_by=request.user, physical_location=location) - logger.info( - f"Event created by user {request.user.id} with location {location.id}" - ) + event = serializer.save() + logger.info(f"Event created by user {request.user.id}") except ValidationError as e: logger.exception( f"Validation failed for event creation by user {request.user.id}: {e}" ) - Location.objects.filter(id=location.id).delete() return Response( {"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST, ) - except (IntegrityError, OperationalError) as e: - logger.exception(f"Failed to create event for user {request.user.id}: {e}") - Location.objects.filter(id=location.id).delete() - return Response( - {"detail": "Failed to create event."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - return Response(serializer.data, status=status.HTTP_201_CREATED) + response_serializer = EventSerializer(event) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) # MARK: Detail API @@ -718,13 +705,17 @@ def get(self, request: Request) -> HttpResponse | Response: ical_event = ICalEvent() ical_event.add("summary", event.name) ical_event.add("description", event.tagline or "") - ical_event.add("dtstart", event.start_time) - ical_event.add("dtend", event.end_time) + + # Get the first event time if available. + if first_time := event.times.first(): + ical_event.add("dtstart", first_time.start_time) + ical_event.add("dtend", first_time.end_time) + ical_event.add( "location", ( event.online_location_link - if event.setting == "online" + if event.location_type == "online" else event.physical_location ), ) diff --git a/frontend/app/app.vue b/frontend/app/app.vue index b91bed6f5..5bd66e716 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -15,6 +15,7 @@ import { useMagicKeys, whenever } from "@vueuse/core"; import { Toaster } from "vue-sonner"; import "vue-sonner/style.css"; +import "v-calendar/style.css"; const { openModal: openModalCommandPalette } = useModalHandlers( "ModalCommandPalette" diff --git a/frontend/app/components/btn/BtnTag.vue b/frontend/app/components/btn/BtnTag.vue index 62db3b531..6422f7418 100644 --- a/frontend/app/components/btn/BtnTag.vue +++ b/frontend/app/components/btn/BtnTag.vue @@ -22,7 +22,7 @@ diff --git a/frontend/app/components/form/dateTime/FormDateTime.vue b/frontend/app/components/form/dateTime/FormDateTime.vue index e92e322b3..fa756535d 100644 --- a/frontend/app/components/form/dateTime/FormDateTime.vue +++ b/frontend/app/components/form/dateTime/FormDateTime.vue @@ -17,7 +17,6 @@ diff --git a/frontend/app/components/form/selector/FormSelectorCombobox.vue b/frontend/app/components/form/selector/FormSelectorCombobox.vue index f2e2a5d68..26883e4a5 100644 --- a/frontend/app/components/form/selector/FormSelectorCombobox.vue +++ b/frontend/app/components/form/selector/FormSelectorCombobox.vue @@ -23,7 +23,6 @@ :label="label" :modelValue="query" :onBlur="onBlur" - :placeholder="label" />