diff --git a/.github/workflows/frontend-lint.yml b/.github/workflows/frontend-lint.yml index e51082d582..9cd4b8028a 100644 --- a/.github/workflows/frontend-lint.yml +++ b/.github/workflows/frontend-lint.yml @@ -34,5 +34,7 @@ jobs: run: pnpm install - name: Codegen run: pnpm run codegen + env: + API_URL_SERVER: https://pastaporto-admin.pycon.it - name: Run Biome run: biome ci . diff --git a/backend/api/files_upload/permissions.py b/backend/api/files_upload/permissions.py index 090e39af14..c595124dca 100644 --- a/backend/api/files_upload/permissions.py +++ b/backend/api/files_upload/permissions.py @@ -37,9 +37,11 @@ def _check_proposal_material(self, user, input: "UploadFileInput") -> bool: conference_code = input.data.conference_code try: - proposal = Submission.objects.for_conference_code( - conference_code - ).get_by_hashid(proposal_id) + proposal = ( + Submission.objects.for_conference_code(conference_code) + .filter(status=Submission.STATUS.accepted) + .get_by_hashid(proposal_id) + ) except (Submission.DoesNotExist, IndexError): return False diff --git a/backend/api/files_upload/tests/mutations/test_upload_file.py b/backend/api/files_upload/tests/mutations/test_upload_file.py index 646d75c2bb..0ff4848a63 100644 --- a/backend/api/files_upload/tests/mutations/test_upload_file.py +++ b/backend/api/files_upload/tests/mutations/test_upload_file.py @@ -1,6 +1,6 @@ import pytest from files_upload.models import File -from submissions.tests.factories import SubmissionFactory +from submissions.tests.factories import AcceptedSubmissionFactory, SubmissionFactory from conferences.tests.factories import ConferenceFactory from django.test import override_settings @@ -64,7 +64,7 @@ def test_upload_participant_avatar_to_invalid_conf_fails(graphql_client, user): def test_upload_proposal_material_file(graphql_client, user): - proposal = SubmissionFactory(speaker=user) + proposal = AcceptedSubmissionFactory(speaker=user) graphql_client.force_login(user) response = _upload_file( @@ -88,7 +88,26 @@ def test_upload_proposal_material_file(graphql_client, user): def test_cannot_upload_proposal_material_file_if_not_speaker(graphql_client, user): - proposal = SubmissionFactory() + proposal = AcceptedSubmissionFactory() + graphql_client.force_login(user) + + response = _upload_file( + graphql_client, + { + "proposalMaterial": { + "filename": "test.txt", + "proposalId": proposal.hashid, + "conferenceCode": proposal.conference.code, + } + }, + ) + + assert not response["data"] + assert response["errors"][0]["message"] == "You cannot upload files of this type" + + +def test_cannot_upload_proposal_material_file_if_not_accepted(graphql_client, user): + proposal = SubmissionFactory(status="proposed") graphql_client.force_login(user) response = _upload_file( @@ -129,7 +148,7 @@ def test_cannot_upload_proposal_material_file_with_invalid_proposal_id( def test_cannot_upload_proposal_material_file_with_invalid_proposal_id_for_conference( graphql_client, user ): - proposal = SubmissionFactory() + proposal = AcceptedSubmissionFactory() graphql_client.force_login(user) response = _upload_file( @@ -151,7 +170,7 @@ def test_cannot_upload_proposal_material_file_with_invalid_proposal_id_for_confe "file_type", [File.Type.PARTICIPANT_AVATAR, File.Type.PROPOSAL_MATERIAL] ) def test_superusers_can_upload_anything(graphql_client, admin_superuser, file_type): - proposal = SubmissionFactory() + proposal = AcceptedSubmissionFactory() graphql_client.force_login(admin_superuser) req_input = {} diff --git a/backend/api/submissions/mutations.py b/backend/api/submissions/mutations.py index a2c9ab17ee..5249b6a141 100644 --- a/backend/api/submissions/mutations.py +++ b/backend/api/submissions/mutations.py @@ -1,5 +1,6 @@ from urllib.parse import urljoin from django.conf import settings +from conferences.frontend import trigger_frontend_revalidate from grants.tasks import get_name from notifications.models import EmailTemplate, EmailTemplateIdentifier from strawberry.scalars import JSON @@ -20,15 +21,22 @@ from i18n.strings import LazyI18nString from languages.models import Language from participants.models import Participant -from submissions.models import Submission as SubmissionModel +from submissions.models import ProposalMaterial, Submission as SubmissionModel from submissions.tasks import notify_new_cfp_submission -from .types import Submission +from .types import Submission, SubmissionMaterialInput FACEBOOK_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?facebook\.com\/") LINKEDIN_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?linkedin\.com\/") +@strawberry.type +class ProposalMaterialErrors: + file_id: list[str] = strawberry.field(default_factory=list) + url: list[str] = strawberry.field(default_factory=list) + id: list[str] = strawberry.field(default_factory=list) + + @strawberry.type class SendSubmissionErrors(BaseErrorType): @strawberry.type @@ -46,6 +54,7 @@ class _SendSubmissionErrors: audience_level: list[str] = strawberry.field(default_factory=list) tags: list[str] = strawberry.field(default_factory=list) short_social_summary: list[str] = strawberry.field(default_factory=list) + materials: list[ProposalMaterialErrors] = strawberry.field(default_factory=list) speaker_bio: list[str] = strawberry.field(default_factory=list) speaker_photo: list[str] = strawberry.field(default_factory=list) @@ -247,6 +256,22 @@ class UpdateSubmissionInput(BaseSubmissionInput): topic: Optional[ID] = strawberry.field(default=None) tags: list[ID] = strawberry.field(default_factory=list) + materials: list[SubmissionMaterialInput] = strawberry.field(default_factory=list) + + def validate(self, conference: Conference, submission: SubmissionModel): + errors = super().validate(conference) + + if self.materials: + if len(self.materials) > 3: + errors.add_error( + "non_field_errors", "You can only add up to 3 materials" + ) + else: + for index, material in enumerate(self.materials): + with errors.with_prefix("materials", index): + material.validate(errors, submission) + + return errors SendSubmissionOutput = Annotated[ @@ -276,7 +301,7 @@ def update_submission( conference = instance.conference - errors = input.validate(conference=conference) + errors = input.validate(conference=conference, submission=instance) if errors.has_errors: return errors @@ -294,6 +319,7 @@ def update_submission( instance.speaker_level = input.speaker_level instance.previous_talk_video = input.previous_talk_video instance.short_social_summary = input.short_social_summary + languages = Language.objects.filter(code__in=input.languages).all() instance.languages.set(languages) @@ -301,6 +327,48 @@ def update_submission( instance.save() + materials_to_create = [] + materials_to_update = [] + + existing_materials = { + existing_material.id: existing_material + for existing_material in instance.materials.all() + } + for material in input.materials: + existing_material = ( + existing_materials.get(int(material.id)) if material.id else None + ) + + if existing_material: + existing_material.name = material.name + existing_material.url = material.url + existing_material.file_id = material.file_id + materials_to_update.append(existing_material) + else: + materials_to_create.append( + ProposalMaterial( + proposal=instance, + name=material.name, + url=material.url, + file_id=material.file_id, + ) + ) + + if to_delete := [ + m.id for m in existing_materials.values() if m not in materials_to_update + ]: + ProposalMaterial.objects.filter( + proposal=instance, + id__in=to_delete, + ).delete() + + if materials_to_create: + ProposalMaterial.objects.bulk_create(materials_to_create) + if materials_to_update: + ProposalMaterial.objects.bulk_update( + materials_to_update, fields=["name", "url", "file_id"] + ) + Participant.objects.update_or_create( user_id=request.user.id, conference=conference, @@ -319,6 +387,8 @@ def update_submission( }, ) + trigger_frontend_revalidate(conference, instance) + instance.__strawberry_definition__ = Submission.__strawberry_definition__ return instance diff --git a/backend/api/submissions/tests/test_edit_submission.py b/backend/api/submissions/tests/test_edit_submission.py index 6c484a3339..7a671420fb 100644 --- a/backend/api/submissions/tests/test_edit_submission.py +++ b/backend/api/submissions/tests/test_edit_submission.py @@ -1,10 +1,21 @@ +import pytest +from uuid import uuid4 +from users.tests.factories import UserFactory from conferences.tests.factories import ConferenceFactory -from submissions.tests.factories import SubmissionFactory, SubmissionTagFactory -from files_upload.tests.factories import FileFactory +from submissions.tests.factories import ( + ProposalMaterialFactory, + SubmissionFactory, + SubmissionTagFactory, +) +from files_upload.tests.factories import ( + FileFactory, + ParticipantAvatarFileFactory, + ProposalMaterialFileFactory, +) from pytest import mark from participants.models import Participant -from submissions.models import Submission +from submissions.models import ProposalMaterial, Submission pytestmark = mark.django_db @@ -13,11 +24,11 @@ def _update_submission( graphql_client, *, submission, - new_topic, - new_audience, - new_type, - new_tag, - new_duration, + new_topic=None, + new_audience=None, + new_type=None, + new_tag=None, + new_duration=None, new_title=None, new_elevator_pitch=None, new_abstract=None, @@ -34,13 +45,20 @@ def _update_submission( new_speaker_facebook_url="", new_speaker_mastodon_handle="", new_speaker_availabilities=None, + new_materials=None, ): + new_topic = new_topic or submission.topic + new_audience = new_audience or submission.audience_level + new_type = new_type or submission.type + new_tag = new_tag or submission.tags.first() + new_duration = new_duration or submission.duration new_title = new_title or {"en": "new title to use"} new_elevator_pitch = new_elevator_pitch or {"en": "This is an elevator pitch"} new_abstract = new_abstract or {"en": "abstract here"} short_social_summary = new_short_social_summary or "" new_speaker_photo = new_speaker_photo or FileFactory().id new_speaker_availabilities = new_speaker_availabilities or {} + new_materials = new_materials or [] return graphql_client.query( """ @@ -114,6 +132,11 @@ def _update_submission( validationSpeakerInstagramHandle: speakerInstagramHandle validationSpeakerLinkedinUrl: speakerLinkedinUrl validationSpeakerFacebookUrl: speakerFacebookUrl + validationMaterials: materials { + fileId + url + id + } } } } @@ -144,6 +167,7 @@ def _update_submission( "speakerFacebookUrl": new_speaker_facebook_url, "speakerMastodonHandle": new_speaker_mastodon_handle, "speakerAvailabilities": new_speaker_availabilities, + "materials": new_materials, } }, ) @@ -204,6 +228,533 @@ def test_update_submission(graphql_client, user): assert participant.linkedin_url == "http://linkedin.com/company/pythonpizza" +def test_update_submission_with_materials(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + new_file = ProposalMaterialFileFactory(uploaded_by=user) + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "fileId": new_file.id, + "url": "", + "name": "test.pdf", + }, + { + "fileId": None, + "url": "https://www.google.com", + "name": "https://www.google.com", + }, + ], + ) + + submission.refresh_from_db() + materials = submission.materials.order_by("id").all() + + assert len(materials) == 2 + assert materials[0].file_id == new_file.id + assert materials[0].url == "" + assert materials[0].name == "test.pdf" + + assert materials[1].file_id is None + assert materials[1].url == "https://www.google.com" + assert materials[1].name == "https://www.google.com" + + assert response["data"]["updateSubmission"]["__typename"] == "Submission" + + +def test_update_submission_with_existing_materials(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + existing_material = ProposalMaterialFactory( + proposal=submission, file=ProposalMaterialFileFactory(uploaded_by=user) + ) + to_delete_material = ProposalMaterialFactory( + proposal=submission, file=None, url="https://www.google.com" + ) + + graphql_client.force_login(user) + + new_file = ProposalMaterialFileFactory(uploaded_by=user) + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "fileId": new_file.id, + "url": "", + "name": "test.pdf", + }, + { + "id": existing_material.id, + "fileId": None, + "url": "https://www.google.com", + "name": "https://www.google.com", + }, + ], + ) + + submission.refresh_from_db() + materials = submission.materials.order_by("id").all() + + assert len(materials) == 2 + + existing_material.refresh_from_db() + + assert existing_material.file_id is None + assert existing_material.url == "https://www.google.com" + assert existing_material.name == "https://www.google.com" + + assert response["data"]["updateSubmission"]["__typename"] == "Submission" + + assert not ProposalMaterial.objects.filter(id=to_delete_material.id).exists() + + +def test_update_submission_with_invalid_url(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "fileId": None, + "url": "invalid-url", + "name": "test.pdf", + }, + ], + ) + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"]["validationMaterials"][0][ + "url" + ] == ["Invalid URL"] + + +def test_update_submission_with_other_submission_material(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + other_submission_material = ProposalMaterialFactory( + proposal=SubmissionFactory(conference=conference), + file=ProposalMaterialFileFactory(uploaded_by=user), + ) + + graphql_client.force_login(user) + + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "id": other_submission_material.id, + "fileId": None, + "url": "https://www.google.com", + "name": "https://www.google.com", + }, + ], + ) + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"]["validationMaterials"][0][ + "id" + ] == ["Material not found"] + + +def test_update_submission_with_invalid_material_id(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "id": "invalid-id", + "fileId": None, + "url": "https://www.google.com", + "name": "https://www.google.com", + }, + ], + ) + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"]["validationMaterials"][0][ + "id" + ] == ["Invalid material id"] + + +def test_update_submission_with_nonexistent_file_id(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "fileId": uuid4(), + "url": "", + "name": "name", + }, + ], + ) + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"]["validationMaterials"][0][ + "fileId" + ] == ["File not found"] + + +def test_update_submission_with_file_from_different_user(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "fileId": ProposalMaterialFileFactory(uploaded_by=UserFactory()).id, + "url": "", + "name": "name", + }, + ], + ) + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"]["validationMaterials"][0][ + "fileId" + ] == ["File not found"] + + +def test_update_submission_with_wrong_file_type(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "fileId": ParticipantAvatarFileFactory(uploaded_by=user).id, + "url": "", + "name": "name", + }, + ], + ) + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"]["validationMaterials"][0][ + "fileId" + ] == ["File not found"] + + +def test_update_submission_with_too_long_url(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "fileId": None, + "url": f"https://www.googl{'e' * 2049}.com", + "name": "name", + }, + ], + ) + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"]["validationMaterials"][0][ + "url" + ] == ["URL is too long"] + + +@pytest.mark.parametrize( + "url", + [ + "ftp://www.google.com", + "//www.google.com", + "google.com/test", + "no/url", + ], +) +def test_update_submission_with_invalid_urls(graphql_client, user, url): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "fileId": None, + "url": url, + "name": "name", + }, + ], + ) + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"]["validationMaterials"][0][ + "url" + ] == ["Invalid URL"] + + +def test_update_submission_with_too_many_materials(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + response = _update_submission( + graphql_client, + submission=submission, + new_materials=[ + { + "fileId": None, + "url": "https://www.google.com", + "name": "test.pdf", + }, + ] + * 4, + ) + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"]["nonFieldErrors"] == [ + "You can only add up to 3 materials" + ] + + def test_update_submission_speaker_availabilities(graphql_client, user): conference = ConferenceFactory( topics=("life", "diy"), diff --git a/backend/api/submissions/tests/test_submissions.py b/backend/api/submissions/tests/test_submissions.py index 933dae3efd..1379fc4bdf 100644 --- a/backend/api/submissions/tests/test_submissions.py +++ b/backend/api/submissions/tests/test_submissions.py @@ -16,14 +16,14 @@ def test_submissions_are_random_by_user(graphql_client, mock_has_ticket): graphql_client.force_login(user_1) - submission = SubmissionFactory(id=1) - submission_2 = SubmissionFactory(id=2, conference=submission.conference) - submission_3 = SubmissionFactory(id=3, conference=submission.conference) + submission = SubmissionFactory() + SubmissionFactory(conference=submission.conference) + SubmissionFactory(conference=submission.conference) mock_has_ticket(submission.conference) query = """query Submissions($code: String!, $page: Int) { - submissions(code: $code, page: $page, pageSize: 1) { + submissions(code: $code, page: $page, pageSize: 3) { pageInfo { totalPages totalItems @@ -34,35 +34,21 @@ def test_submissions_are_random_by_user(graphql_client, mock_has_ticket): } }""" - data_proposals = { - user_1: [ - {"id": submission_3.hashid}, - {"id": submission_2.hashid}, - {"id": submission.hashid}, - ], - user_2: [ - {"id": submission.hashid}, - {"id": submission_2.hashid}, - {"id": submission_3.hashid}, - ], - user_3: [ - {"id": submission_2.hashid}, - {"id": submission_3.hashid}, - {"id": submission.hashid}, - ], - } + submissions = {} for user in [user_1, user_2, user_3]: graphql_client.force_login(user) - for page in range(3): - resp = graphql_client.query( - query, - variables={"code": submission.conference.code, "page": page + 1}, - ) + resp = graphql_client.query( + query, + variables={"code": submission.conference.code, "page": 1}, + ) + + submissions[user] = resp["data"]["submissions"]["items"] - assert not resp.get("errors") - assert resp["data"]["submissions"]["items"] == [data_proposals[user][page]] + assert submissions[user_1] != submissions[user_2] + assert submissions[user_1] != submissions[user_3] + assert submissions[user_2] != submissions[user_3] def test_returns_submissions_paginated(graphql_client, user): diff --git a/backend/api/submissions/types.py b/backend/api/submissions/types.py index 86aca23917..128bcb18b3 100644 --- a/backend/api/submissions/types.py +++ b/backend/api/submissions/types.py @@ -1,4 +1,8 @@ from typing import Annotated +from submissions.models import Submission as SubmissionModel + +from files_upload.models import File +from api.utils import validate_url from participants.models import Participant as ParticipantModel import strawberry from strawberry.types.field import StrawberryField @@ -17,6 +21,7 @@ from api.conferences.types import Conference, Topic, Duration, AudienceLevel from api.schedule.types import ScheduleItem from api.participants.types import Participant + from api.submissions.mutations import SendSubmissionErrors def private_field() -> StrawberryField: @@ -81,6 +86,7 @@ class ProposalMaterial: id: strawberry.ID name: str url: str | None + file_id: str | None file_url: str | None file_mime_type: str | None @@ -90,6 +96,7 @@ def from_django(cls, material): id=material.id, name=material.name, url=material.url, + file_id=material.file_id, file_url=material.file.url if material.file_id else None, file_mime_type=material.file.mime_type if material.file_id else None, ) @@ -199,3 +206,37 @@ def materials(self, info) -> list[ProposalMaterial]: class SubmissionsPagination: submissions: list[Submission] total_pages: int + + +@strawberry.input +class SubmissionMaterialInput: + name: str + id: strawberry.ID | None = None + url: str | None = None + file_id: str | None = None + + def validate( + self, errors: "SendSubmissionErrors", submission: SubmissionModel + ) -> "SendSubmissionErrors": + if self.id: + try: + if not submission.materials.filter(id=int(self.id)).exists(): + errors.add_error("id", "Material not found") + except ValueError: + errors.add_error("id", "Invalid material id") + + if self.file_id: + if not File.objects.filter( + id=self.file_id, + uploaded_by_id=submission.speaker_id, + type=File.Type.PROPOSAL_MATERIAL, + ).exists(): + errors.add_error("file_id", "File not found") + + if self.url: + if len(self.url) > 2048: + errors.add_error("url", "URL is too long") + elif not validate_url(self.url): + errors.add_error("url", "Invalid URL") + + return errors diff --git a/backend/api/tests/test_utils.py b/backend/api/tests/test_utils.py index 1ed8731b82..2c71aa4d90 100644 --- a/backend/api/tests/test_utils.py +++ b/backend/api/tests/test_utils.py @@ -1,4 +1,5 @@ -from api.utils import get_ip +import pytest +from api.utils import get_ip, validate_url def test_get_ip(rf): @@ -9,3 +10,19 @@ def test_get_ip(rf): def test_get_ip_with_remote_addr(rf): request = rf.get("/", REMOTE_ADDR="2.2.2.2") assert get_ip(request) == "2.2.2.2" + + +@pytest.mark.parametrize( + "url, expected", + [ + ("https://www.google.com", True), + ("http://www.google.com", True), + ("https://www.google.com/search?q=test", True), + ("https://www.google.com/search?q=test", True), + ("https://www.google.com/search?q=test", True), + ("http://", False), + ("https://", False), + ], +) +def test_validate_url(url, expected): + assert validate_url(url) == expected diff --git a/backend/api/utils.py b/backend/api/utils.py index e616bdb5ca..f8147866c6 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -1,5 +1,8 @@ +from urllib.parse import urlparse +from django.core.validators import ( + validate_email as original_validate_email, +) from django.core.exceptions import ValidationError -from django.core.validators import validate_email as original_validate_email def get_ip(request): @@ -16,3 +19,15 @@ def validate_email(email: str) -> bool: return False return True + + +def validate_url(url: str) -> bool: + parsed_url = urlparse(url) + + if parsed_url.scheme not in ["http", "https"]: + return False + + if not parsed_url.netloc: + return False + + return True diff --git a/backend/conferences/frontend.py b/backend/conferences/frontend.py new file mode 100644 index 0000000000..2813a0f64b --- /dev/null +++ b/backend/conferences/frontend.py @@ -0,0 +1,44 @@ +from django.db import models +from submissions.models import Submission +from schedule.models import ScheduleItem +from job_board.models import JobListing +from cms.components.page.tasks import execute_frontend_revalidate +from conferences.models.conference import Conference + + +def trigger_frontend_revalidate(conference: Conference, object: models.Model): + if not conference.frontend_revalidate_url: + return + + for path in get_paths(object): + for locale in ["en", "it"]: + execute_frontend_revalidate.delay( + url=conference.frontend_revalidate_url, + path=f"/{locale}{path}", + secret=conference.frontend_revalidate_secret, + ) + + +def get_paths(object: models.Model) -> list[str]: + match object: + case JobListing(): + return [ + f"/jobs/{object.id}", + "/jobs/", + ] + case ScheduleItem(type=ScheduleItem.TYPES.keynote): + return [ + f"/keynotes/{object.slug}", + ] + case ScheduleItem(): + return [ + f"/event/{object.slug}", + ] + case Submission(status=Submission.STATUS.accepted): + schedule_items = ScheduleItem.objects.filter( + submission_id=object.id, + conference=object.conference, + ) + return [f"/event/{schedule_item.slug}" for schedule_item in schedule_items] + case _: + return [] diff --git a/backend/conferences/tests/test_frontend.py b/backend/conferences/tests/test_frontend.py new file mode 100644 index 0000000000..eeb767c612 --- /dev/null +++ b/backend/conferences/tests/test_frontend.py @@ -0,0 +1,92 @@ +from submissions.models import Submission +from submissions.tests.factories import SubmissionFactory +from conferences.tests.factories import ConferenceFactory +from job_board.tests.factories import JobListingFactory +from schedule.tests.factories import ScheduleItemFactory +from conferences.frontend import get_paths, trigger_frontend_revalidate +from schedule.models import ScheduleItem + + +def test_get_paths_for_keynote(): + keynote = ScheduleItem( + slug="keynote-1", + type=ScheduleItem.TYPES.keynote, + ) + + assert get_paths(keynote) == [ + "/keynotes/keynote-1", + ] + + +def test_get_paths_for_event(): + event = ScheduleItemFactory( + slug="event-1", + type=ScheduleItem.TYPES.submission, + ) + + assert get_paths(event) == [ + "/event/event-1", + ] + + +def test_get_paths_for_job_listing(): + job_listing = JobListingFactory( + id=1, + ) + + assert get_paths(job_listing) == [ + "/jobs/1", + "/jobs/", + ] + + +def test_get_paths_for_unknown_object(): + assert get_paths(object()) == [] + + +def test_get_paths_for_accepted_submission_invalidates_schedule_items(): + submission = SubmissionFactory( + status=Submission.STATUS.accepted, + ) + + schedule_item = ScheduleItemFactory( + submission=submission, + conference=submission.conference, + type=ScheduleItem.TYPES.talk, + ) + + assert get_paths(submission) == [ + f"/event/{schedule_item.slug}", + ] + + +def test_trigger_frontend_revalidate(mocker): + mock_call = mocker.patch("conferences.frontend.execute_frontend_revalidate.delay") + + conference = ConferenceFactory( + frontend_revalidate_url="https://example.com", + frontend_revalidate_secret="secret", + ) + + object = ScheduleItemFactory( + slug="event-1", + type=ScheduleItem.TYPES.submission, + ) + + trigger_frontend_revalidate(conference, object) + + mock_call.assert_has_calls( + [ + mocker.call( + url="https://example.com", + path="/en/event/event-1", + secret="secret", + ), + mocker.call( + url="https://example.com", + path="/it/event/event-1", + secret="secret", + ), + ], + any_order=True, + ) diff --git a/backend/files_upload/tests/factories.py b/backend/files_upload/tests/factories.py index 07189f56db..c3a1424290 100644 --- a/backend/files_upload/tests/factories.py +++ b/backend/files_upload/tests/factories.py @@ -9,3 +9,11 @@ class Meta: file = factory.django.FileField(filename="test.txt", data=b"test data") type = File.Type.PARTICIPANT_AVATAR uploaded_by = factory.SubFactory("users.tests.factories.UserFactory") + + +class ProposalMaterialFileFactory(FileFactory): + type = File.Type.PROPOSAL_MATERIAL + + +class ParticipantAvatarFileFactory(FileFactory): + type = File.Type.PARTICIPANT_AVATAR diff --git a/backend/job_board/admin.py b/backend/job_board/admin.py index 1d8fe640b6..1506c250f3 100644 --- a/backend/job_board/admin.py +++ b/backend/job_board/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from cms.components.page.tasks import execute_frontend_revalidate +from conferences.frontend import trigger_frontend_revalidate from ordered_model.admin import OrderedModelAdmin from custom_admin.widgets import RichEditorWidget @@ -25,14 +25,4 @@ def save_model(self, request, obj, form, change): if not conference.frontend_revalidate_url: return - for locale in ["en", "it"]: - execute_frontend_revalidate.delay( - url=conference.frontend_revalidate_url, - path=f"/{locale}/jobs/", - secret=conference.frontend_revalidate_secret, - ) - execute_frontend_revalidate.delay( - url=conference.frontend_revalidate_url, - path=f"/{locale}/jobs/{obj.id}", - secret=conference.frontend_revalidate_secret, - ) + trigger_frontend_revalidate(conference, obj) diff --git a/backend/submissions/tests/factories.py b/backend/submissions/tests/factories.py index a8cdc8bcc4..afba238571 100644 --- a/backend/submissions/tests/factories.py +++ b/backend/submissions/tests/factories.py @@ -127,6 +127,10 @@ def _after_postgeneration(cls, obj, create, results=None): obj.save() +class AcceptedSubmissionFactory(SubmissionFactory): + status = "accepted" + + class SubmissionCommentFactory(DjangoModelFactory): class Meta: model = SubmissionComment diff --git a/frontend/src/components/blocks/live-streaming-section/index.tsx b/frontend/src/components/blocks/live-streaming-section/index.tsx index 82aecb773d..58576f14c5 100644 --- a/frontend/src/components/blocks/live-streaming-section/index.tsx +++ b/frontend/src/components/blocks/live-streaming-section/index.tsx @@ -41,7 +41,9 @@ export const LiveStreamingSection = () => { }; const runningEvent = currentDay?.runningEvents?.filter((event) => - (event.livestreamingRoom ? [event.livestreamingRoom] : event.rooms).map((room) => room.id).includes(currentRoom.id), + (event.livestreamingRoom ? [event.livestreamingRoom] : event.rooms) + .map((room) => room.id) + .includes(currentRoom.id), )?.[0]; const hasLiveVideos = currentDay?.rooms?.some((room) => room.streamingUrl); @@ -162,7 +164,8 @@ const isRoomStreaming = ( ) => { return runningEvents.some( (event) => - (event.livestreamingRoom ? [event.livestreamingRoom] : event.rooms).map((room) => room.id).includes(room.id) && - event.type !== "custom", + (event.livestreamingRoom ? [event.livestreamingRoom] : event.rooms) + .map((room) => room.id) + .includes(room.id) && event.type !== "custom", ); }; diff --git a/frontend/src/components/cfp-form/index.tsx b/frontend/src/components/cfp-form/index.tsx index 122f63aa94..dc20d04981 100644 --- a/frontend/src/components/cfp-form/index.tsx +++ b/frontend/src/components/cfp-form/index.tsx @@ -29,8 +29,33 @@ import { } from "../public-profile-card"; import { AboutYouSection } from "./about-you-section"; import { AvailabilitySection } from "./availability-section"; +import { MaterialsSection } from "./materials-section"; import { ProposalSection } from "./proposal-section"; +export type GetErrorsKey = + | "validationTitle" + | "validationAbstract" + | "validationLanguages" + | "validationType" + | "validationDuration" + | "validationElevatorPitch" + | "validationNotes" + | "validationAudienceLevel" + | "validationTags" + | "validationSpeakerLevel" + | "validationPreviousTalkVideo" + | "validationShortSocialSummary" + | "validationSpeakerBio" + | "validationSpeakerWebsite" + | "validationSpeakerPhoto" + | "validationSpeakerTwitterHandle" + | "validationSpeakerInstagramHandle" + | "validationSpeakerLinkedinUrl" + | "validationSpeakerFacebookUrl" + | "validationSpeakerMastodonHandle" + | "validationMaterials" + | "nonFieldErrors"; + export type CfpFormFields = ParticipantFormFields & { type: string; title: { it?: string; en?: string }; @@ -46,10 +71,13 @@ export type CfpFormFields = ParticipantFormFields & { shortSocialSummary: string; acceptedPrivacyPolicy: boolean; speakerAvailabilities: { [time: number]: null | string }; + materials: any[]; }; export type SubmissionStructure = { + id: string; type: { id: string }; + status: string; title: string; elevatorPitch: string; abstract: string; @@ -64,6 +92,14 @@ export type SubmissionStructure = { speakerLevel: string; tags: { id: string }[]; shortSocialSummary: string; + materials: { + id: string; + name: string; + url: string; + fileId: string; + fileUrl: string; + fileMimeType: string; + }[]; }; type Props = { @@ -171,6 +207,7 @@ export const CfpForm = ({ participantData.me.participant?.photoId, acceptedPrivacyPolicy: formState.values.acceptedPrivacyPolicy, speakerAvailabilities: formState.values.speakerAvailabilities, + materials: formState.values.materials, }); }; @@ -221,6 +258,16 @@ export const CfpForm = ({ ); formState.setField("shortSocialSummary", submission!.shortSocialSummary); formState.setField("acceptedPrivacyPolicy", true); + formState.setField( + "materials", + submission!.materials.map((material) => ({ + type: material.fileId ? "file" : "link", + id: material.id, + fileId: material.fileId, + url: material.url, + name: material.name, + })), + ); } if (participantData.me.participant) { @@ -271,31 +318,7 @@ export const CfpForm = ({ const hasValidationErrors = submissionData?.mutationOp.__typename === "SendSubmissionErrors"; - /* todo refactor to avoid multiple __typename? */ - const getErrors = ( - key: - | "validationTitle" - | "validationAbstract" - | "validationLanguages" - | "validationType" - | "validationDuration" - | "validationElevatorPitch" - | "validationNotes" - | "validationAudienceLevel" - | "validationTags" - | "validationSpeakerLevel" - | "validationPreviousTalkVideo" - | "validationShortSocialSummary" - | "validationSpeakerBio" - | "validationSpeakerWebsite" - | "validationSpeakerPhoto" - | "validationSpeakerTwitterHandle" - | "validationSpeakerInstagramHandle" - | "validationSpeakerLinkedinUrl" - | "validationSpeakerFacebookUrl" - | "validationSpeakerMastodonHandle" - | "nonFieldErrors", - ): string[] => + const getErrors = (key: GetErrorsKey): any => (submissionData?.mutationOp.__typename === "SendSubmissionErrors" && submissionData!.mutationOp.errors[key]) || []; @@ -329,6 +352,20 @@ export const CfpForm = ({ conferenceData={conferenceData} /> + {submission?.status === "accepted" && ( + <> + + + + + )} + >; + formOptions: Inputs; + getErrors: (field: GetErrorsKey) => any; + submission: SubmissionStructure; +}; + +const MAX_MATERIALS = 3; + +export const MaterialsSection = ({ + formState, + getErrors, + submission, +}: Props) => { + const materials = formState.values.materials ?? []; + + const onAddFile = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + if (materials.length >= MAX_MATERIALS) { + return; + } + + const newMaterials = [ + ...materials, + { + type: "file", + }, + ]; + formState.setField("materials", newMaterials); + }, + [formState, materials], + ); + + const onAddURL = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + if (materials.length >= MAX_MATERIALS) { + return; + } + + const newMaterials = [ + ...materials, + { + type: "link", + }, + ]; + formState.setField("materials", newMaterials); + }, + [formState, materials], + ); + + const inputPlaceholder = useTranslatedMessage("input.placeholder"); + + return ( + + + + + + + + + + + + + + {materials.map((material, index) => ( + +
+ {material.type === "file" ? ( + { + const newMaterials = [...materials]; + newMaterials[index].fileId = fileId; + newMaterials[index].name = info?.name; + formState.setField("materials", newMaterials); + }} + fileAttributes={{ + proposalId: submission?.id, + }} + showPreview={false} + currentFileName={material.name} + /> + ) : ( + { + const newMaterials = [...materials]; + newMaterials[index].url = e.target.value; + newMaterials[index].name = e.target.value; + formState.setField("materials", newMaterials); + }} + /> + )} +
+
+ +
+
+ ))} + + + + + + + +
+
+
+ ); +}; diff --git a/frontend/src/components/file-input/index.tsx b/frontend/src/components/file-input/index.tsx index 8687ae8874..df94b0cb59 100644 --- a/frontend/src/components/file-input/index.tsx +++ b/frontend/src/components/file-input/index.tsx @@ -1,7 +1,11 @@ import { FileInput as FileInputUI, + HorizontalStack, + Spacer, Text, + VerticalStack, } from "@python-italia/pycon-styleguide"; +import clsx from "clsx"; import React, { type ChangeEvent, useRef, useState } from "react"; import { FormattedMessage } from "react-intl"; @@ -13,8 +17,6 @@ import { import { useCurrentLanguage } from "~/locale/context"; import { useFinalizeUploadMutation, useUploadFileMutation } from "~/types"; -import { ErrorsList } from "../errors-list"; - const MAX_UPLOAD_SIZE_IN_MB = 1 * 1024 * 1024; export const FileInput = ({ @@ -25,14 +27,20 @@ export const FileInput = ({ type, previewUrl, accept, + fileAttributes, + showPreview = true, + currentFileName, }: { - onChange: (value: string) => void; + onChange: (value: string, info?: { name?: string }) => void; name: string; value: string; errors?: string[]; type: "participant_avatar" | "proposal_material"; previewUrl?: string; accept: string; + fileAttributes?: Record; + showPreview?: boolean; + currentFileName?: string; }) => { const conferenceCode = process.env.conferenceCode; const canvas = useRef(undefined); @@ -46,21 +54,20 @@ export const FileInput = ({ const [error, setError] = useState(""); const [selectedFile, setSelectedFile] = useState(null); - const resetInput = () => { + const resetInput = (triggerOnChange = true) => { setError(""); if (filePreview) { URL.revokeObjectURL(filePreview); - baseOnChange(null); + if (triggerOnChange) { + baseOnChange(null); + } } setFilePreview(null); }; const onChange = (file: File) => { - setSelectedFile(file); - console.log("file", file); - if (!file) { resetInput(); return; @@ -74,13 +81,19 @@ export const FileInput = ({ return; } - resetInput(); - const fakeImg = document.createElement("img"); - fakeImg.onload = () => onImageLoaded(fakeImg); - fakeImg.src = URL.createObjectURL(file); + resetInput(false); + setSelectedFile(file); + + if (file.type.startsWith("image/")) { + const fakeImg = document.createElement("img"); + fakeImg.onload = () => onImageLoaded(fakeImg, file); + fakeImg.src = URL.createObjectURL(file); + } else { + startUploadFlow(file); + } }; - const onImageLoaded = (image: HTMLImageElement) => { + const onImageLoaded = (image: HTMLImageElement, fileInfo: File) => { // convert the image to jpeg // in the future this could do more (resize etc) const context = canvas.current.getContext("2d"); @@ -89,7 +102,7 @@ export const FileInput = ({ context.drawImage(image, 0, 0); canvas.current.toBlob( (blob) => { - const file = new File([blob], "converted.jpg", { + const file = new File([blob], `${fileInfo.name.split(".")[0]}.jpg`, { type: "application/octet-stream", }); setFilePreview(URL.createObjectURL(file)); @@ -109,26 +122,35 @@ export const FileInput = ({ participantAvatar: { conferenceCode, filename: file.name, + ...fileAttributes, + }, + }; + } else if (type === "proposal_material") { + input = { + proposalMaterial: { + conferenceCode, + filename: file.name, + ...fileAttributes, }, }; } - const { data, errors } = await uploadFile({ - variables: { - input, - }, - }); + try { + const { data, errors } = await uploadFile({ + variables: { + input, + }, + }); - const response = data.uploadFile; + const response = data.uploadFile; - if (errors || response.__typename !== "FileUploadRequest") { - setError(getTranslatedMessage("fileInput.uploadFailed", language)); - return; - } + if (errors || response.__typename !== "FileUploadRequest") { + setError(getTranslatedMessage("fileInput.uploadFailed", language)); + return; + } - const uploadUrl = response.uploadUrl; - const uploadFields = JSON.parse(response.fields); - try { + const uploadUrl = response.uploadUrl; + const uploadFields = JSON.parse(response.fields); const formData = new FormData(); Object.keys(uploadFields).forEach((key) => { formData.append(key, uploadFields[key]); @@ -154,9 +176,15 @@ export const FileInput = ({ }, }); - baseOnChange(fileId); + baseOnChange(fileId, { + name: file.name, + }); } catch (e) { - setError(getTranslatedMessage("fileInput.uploadFailed", language)); + const baseMessage = getTranslatedMessage( + "fileInput.uploadFailed", + language, + ); + setError(`${baseMessage}: ${e.message}`); } finally { setIsUploading(false); } @@ -177,21 +205,27 @@ export const FileInput = ({ errors={allErrors} /> - - - {isUploading && ( - - - - )} - - {previewAvailable && ( + + {isUploading && } + {!isUploading && !showPreview && currentFileName && ( + + )} + + + {previewAvailable && showPreview && ( Selection preview )} + + ); }; diff --git a/frontend/src/components/my-proposals-profile-page-handler/my-proposals-table.tsx b/frontend/src/components/my-proposals-profile-page-handler/my-proposals-table.tsx index 4c27234305..20e5eb58b3 100644 --- a/frontend/src/components/my-proposals-profile-page-handler/my-proposals-table.tsx +++ b/frontend/src/components/my-proposals-profile-page-handler/my-proposals-table.tsx @@ -53,15 +53,7 @@ export const MyProposalsTable = ({ submissions }: Props) => { , - row.status === "accepted" ? ( - inSchedule ? ( - - ) : ( - - ) - ) : ( - - ), + ,
{inSchedule && row.scheduleItems.map((scheduleItem) => { diff --git a/frontend/src/components/submission/submission.graphql b/frontend/src/components/submission/submission.graphql index 2219becc2e..2f54bee41c 100644 --- a/frontend/src/components/submission/submission.graphql +++ b/frontend/src/components/submission/submission.graphql @@ -28,6 +28,14 @@ query Submission($id: ID!, $language: String!) { id name } + materials { + id + name + url + fileId + fileUrl + fileMimeType + } speaker { id fullName diff --git a/frontend/src/locale/index.ts b/frontend/src/locale/index.ts index 229d2b4552..c245fd71a9 100644 --- a/frontend/src/locale/index.ts +++ b/frontend/src/locale/index.ts @@ -12,6 +12,8 @@ export const messages = { "tickets.description.page": "hotels page", "global.sessions": "Sessions", + "cfp.materials.remove": "X", + "fileInput.currentFile": "Current file: {name}", "input.placeholder": "Type here...", "global.accordion.close": "Close", @@ -663,7 +665,8 @@ Click the box to change. If left empty, we will assume you are available.`, "orderInformation.invalidFiscalCode": "Fiscal code not valid", "orderInformation.pec": "PEC", "orderInformation.sdi": "SDI", - "orderInformation.invalidSDI": "SDI must be 7 characters long (6 for Italian PA)", + "orderInformation.invalidSDI": + "SDI must be 7 characters long (6 for Italian PA)", "orderQuestions.heading": "Order questions", "orderQuestions.attendeeGivenName": "Attendee Given name", @@ -1002,6 +1005,13 @@ If this new timing doesn't work for you, please don't hesitate to let us know be "streaming.qa": "Ask Questions", + "cfp.materials.addFile": "File", + "cfp.materials.addURL": "URL", + "cfp.materials.add": "Add", + "cfp.materials.title": "Materials", + "cfp.materials.description": + "Use this section to upload the slides or other materials you want to share with other attendees. Up to 3 files/urls are allowed.", + "talk.bookToAttend": "This event has limited capacity, you need to book a seat to attend.", "talk.bookCta": "Book now", @@ -1447,7 +1457,8 @@ The sooner you buy your ticket, the more you save!`, "orderInformation.invalidFiscalCode": "Codice fiscale non valido", "orderInformation.pec": "PEC", "orderInformation.sdi": "SDI", - "orderInformation.invalidSDI": "Il codice SDI deve essere di 7 caratteri (6 per una PA)", + "orderInformation.invalidSDI": + "Il codice SDI deve essere di 7 caratteri (6 per una PA)", "orderQuestions.heading": "Domande aggiuntive", "orderQuestions.attendeeName": "Nome partecipante", @@ -2291,6 +2302,15 @@ Clicca sulla casella per cambiare. Se lasciato vuoto, presumeremo che tu sia dis "voting.filterBy": "Filtra per", "voting.all": "Tutti", + + "cfp.materials.title": "Materiali", + "cfp.materials.description": + "Usa questa sezione per caricare i materiali che vuoi condividere con altri partecipanti. Sono consentiti fino a 3 file/URL.", + "cfp.materials.addFile": "File", + "cfp.materials.addURL": "URL", + "cfp.materials.add": "Aggiungi", + "cfp.materials.remove": "X", + "fileInput.currentFile": "File attuale: {name}", }, }; diff --git a/frontend/src/pages/keynotes/[slug]/index.tsx b/frontend/src/pages/keynotes/[slug]/index.tsx index 042ab906cf..5efff0c9f4 100644 --- a/frontend/src/pages/keynotes/[slug]/index.tsx +++ b/frontend/src/pages/keynotes/[slug]/index.tsx @@ -20,9 +20,7 @@ const KeynotePage = () => { const { data: { conference: { - talk: { - slidoUrl, - }, + talk, keynote: { title, description, @@ -42,6 +40,8 @@ const KeynotePage = () => { }, }); + const { slidoUrl } = talk || {}; + const speakersName = speakers.map((speaker) => speaker.fullName).join(" & "); return ( diff --git a/frontend/src/pages/submission/[id]/edit/get-submission.graphql b/frontend/src/pages/submission/[id]/edit/get-submission.graphql index ea5b8ef374..e4cb4d0f31 100644 --- a/frontend/src/pages/submission/[id]/edit/get-submission.graphql +++ b/frontend/src/pages/submission/[id]/edit/get-submission.graphql @@ -1,6 +1,7 @@ query GetSubmission($id: ID!, $language: String!) { submission(id: $id) { id + status title(language: $language) abstract(language: $language) elevatorPitch(language: $language) @@ -46,5 +47,13 @@ query GetSubmission($id: ID!, $language: String!) { id name } + materials { + id + name + url + fileId + fileUrl + fileMimeType + } } } diff --git a/frontend/src/pages/submission/[id]/edit/index.tsx b/frontend/src/pages/submission/[id]/edit/index.tsx index 17340eb91a..8f26ee0fe7 100644 --- a/frontend/src/pages/submission/[id]/edit/index.tsx +++ b/frontend/src/pages/submission/[id]/edit/index.tsx @@ -68,6 +68,14 @@ export const EditSubmissionPage = () => { speakerFacebookUrl: input.participantFacebookUrl, speakerMastodonHandle: input.participantMastodonHandle, speakerAvailabilities: input.speakerAvailabilities, + materials: input.materials + .filter((material) => material.fileId || material.url) + .map((material) => ({ + name: material.name, + id: material.id, + url: material.url, + fileId: material.fileId, + })), }, language, }, diff --git a/frontend/src/pages/submission/[id]/edit/update-submission.graphql b/frontend/src/pages/submission/[id]/edit/update-submission.graphql index cbd7c27f4f..80edf53155 100644 --- a/frontend/src/pages/submission/[id]/edit/update-submission.graphql +++ b/frontend/src/pages/submission/[id]/edit/update-submission.graphql @@ -51,6 +51,14 @@ mutation UpdateSubmission($input: UpdateSubmissionInput!, $language: String!) { id name } + materials { + id + name + url + fileId + fileUrl + fileMimeType + } } ... on SendSubmissionErrors { @@ -76,6 +84,10 @@ mutation UpdateSubmission($input: UpdateSubmissionInput!, $language: String!) { validationSpeakerLinkedinUrl: speakerLinkedinUrl validationSpeakerFacebookUrl: speakerFacebookUrl validationSpeakerMastodonHandle: speakerMastodonHandle + validationMaterials: materials { + fileId + url + } nonFieldErrors } } diff --git a/frontend/src/pages/submission/[id]/index.tsx b/frontend/src/pages/submission/[id]/index.tsx index 8d17fb947b..2ae64be8e6 100644 --- a/frontend/src/pages/submission/[id]/index.tsx +++ b/frontend/src/pages/submission/[id]/index.tsx @@ -79,6 +79,7 @@ export const SubmissionPage = () => { speakers={submission?.speaker ? [submission.speaker] : null} bookable={false} spacesLeft={0} + materials={submission?.materials} sidebarExtras={ {submission.canEdit ? (