diff --git a/client/next.config.mjs b/client/next.config.mjs index 950b3e6..abff464 100644 --- a/client/next.config.mjs +++ b/client/next.config.mjs @@ -1,12 +1,6 @@ -// import os from "node:os"; -// import isInsideContainer from "is-inside-container"; - -// const isWindowsDevContainer = () => -// os.release().toLowerCase().includes("microsoft") && isInsideContainer(); - /** @type {import('next').NextConfig} */ -const config = { +const nextConfig = { reactStrictMode: true, turbopack: { root: import.meta.dirname, @@ -27,4 +21,4 @@ const config = { // : undefined, }; -export default config; +export default nextConfig; diff --git a/client/public/placeholder-icon.svg b/client/public/placeholder-icon.svg new file mode 100644 index 0000000..6879e78 --- /dev/null +++ b/client/public/placeholder-icon.svg @@ -0,0 +1,6 @@ + + + diff --git a/client/src/components/ui/ContributorsList.tsx b/client/src/components/ui/ContributorsList.tsx new file mode 100644 index 0000000..70be6fe --- /dev/null +++ b/client/src/components/ui/ContributorsList.tsx @@ -0,0 +1,36 @@ +import Link from "next/link"; + +import { ArtContributor } from "@/types/art-contributor"; + +interface ContributorsListProps { + contributors: ArtContributor[]; +} + +export default function ContributorsList({ + contributors, +}: ContributorsListProps) { + if (contributors.length === 0) { + return null; + } + + return ( + <> +
Contributors
+
+ {contributors.map((contributor) => ( +
+ e.stopPropagation()} + > + {contributor.member_name} + + {" - "} + {contributor.role} +
+ ))} +
+ + ); +} diff --git a/client/src/components/ui/ImageCard.tsx b/client/src/components/ui/ImageCard.tsx new file mode 100644 index 0000000..d868429 --- /dev/null +++ b/client/src/components/ui/ImageCard.tsx @@ -0,0 +1,111 @@ +import Image from "next/image"; +import { useRouter } from "next/router"; +import React from "react"; + +interface ImageCardProps { + imageSrc?: string; + imageAlt?: string; + children?: React.ReactNode; + backContent?: React.ReactNode; + href?: string; + disableFlip?: boolean; + placeholder?: React.ReactNode; +} + +const ImageCard = ({ + imageSrc, + imageAlt = "Image", + children, + backContent, + href, + disableFlip = false, + placeholder, +}: ImageCardProps) => { + const router = useRouter(); + const [isFlipped, setIsFlipped] = React.useState(false); + const [isMobile, setIsMobile] = React.useState(false); + const [hasImageError, setHasImageError] = React.useState(false); + + React.useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768); + }; + + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); + }, []); + + const handleClick = () => { + // On mobile, navigate directly if href is provided + if (isMobile && href) { + router.push(href); + } else if (backContent && !disableFlip && !hasImageError) { + // On desktop, toggle flip state + setIsFlipped(!isFlipped); + } + }; + + return ( +
+
+
+ {imageSrc && !hasImageError ? ( + <> + {imageAlt} { + setHasImageError(true); + setIsFlipped(false); + }} + /> + {children && ( +
+ {children} +
+ )} + + ) : ( +
+ {placeholder || children || ( + No Image + )} +
+ )} +
+ + {backContent && ( +
+ {backContent} +
+ )} +
+
+ ); +}; + +export default ImageCard; diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index 2e263a1..74eb69d 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -24,6 +24,7 @@ const buttonVariants = cva( sm: "h-9 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", + leftIcon: "h-10 ps-2 pe-4 py-2 flex gap-2", }, }, defaultVariants: { diff --git a/client/src/components/ui/modal/error-modal.tsx b/client/src/components/ui/modal/error-modal.tsx new file mode 100644 index 0000000..69d7a74 --- /dev/null +++ b/client/src/components/ui/modal/error-modal.tsx @@ -0,0 +1,45 @@ +import React, { useState } from "react"; + +interface ErrorModalProps { + message: string | null; + onClose: () => void; +} + +const ErrorModal = ({ message, onClose = () => {} }: ErrorModalProps) => { + const [isVisible, setIsVisible] = useState(true); + if (!isVisible || !message) { + return null; + } + + function onModalClose() { + setIsVisible(false); + onClose(); + } + + return ( + // Backdrop overlay +
+ {/* Modal content container */} +
e.stopPropagation()} // Prevent closing when clicking inside the modal + > +

Error

+

{message}

+
+ +
+
+
+ ); +}; + +export default ErrorModal; diff --git a/client/src/hooks/use-artwork-data.ts b/client/src/hooks/use-artwork-data.ts new file mode 100644 index 0000000..e64c729 --- /dev/null +++ b/client/src/hooks/use-artwork-data.ts @@ -0,0 +1,62 @@ +import { Art } from "@/types/art"; + +export const generateMockArtworks = (count: number): Art[] => { + const artworks: Art[] = []; + for (let i = 1; i <= count; i++) { + artworks.push({ + art_id: i, + name: `Artwork ${i}`, + description: "Mock artwork description", + media: `http://localhost:8000/media/art/mock_artwork_${i}.png`, + active: true, + source_game_id: null, + source_game_name: null, + contributors: [ + { + id: i * 10 + 1, + member_id: i * 10 + 1, + member_name: "Contributor 1", + role: "artist", + }, + { + id: i * 10 + 2, + member_id: i * 10 + 2, + member_name: "Contributor 2", + role: "designer", + }, + ], + showcase_description: `Showcase description for artwork ${i}`, + isMock: true, + }); + } + return artworks; +}; + +export const generateMockArtwork = (id: string): Art => { + return { + art_id: Number(id), + name: "Mock Artwork Title", + description: + "Lorem ipsum dolor sit amet. Non numquam dicta nam autem dicta 33 error molestias et repellat consequatur eum iste expedita est dolorem libero et quas provident!", + media: `http://localhost:8000/media/art/mock_artwork_${id}.png`, + active: true, + source_game_id: null, + source_game_name: null, + contributors: [ + { + id: 1, + member_id: 1, + member_name: "Contributor 1", + role: "user1", + }, + { + id: 2, + member_id: 2, + member_name: "Contributor 2", + role: "user2", + }, + ], + showcase_description: "Featured artwork showcase description", + isMock: true, + }; +}; diff --git a/client/src/pages/artwork/[id].tsx b/client/src/pages/artwork/[id].tsx new file mode 100644 index 0000000..fb26967 --- /dev/null +++ b/client/src/pages/artwork/[id].tsx @@ -0,0 +1,89 @@ +import { ArrowLeft } from "lucide-react"; +import { GetServerSideProps } from "next"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { Button } from "@/components/ui/button"; +import ContributorsList from "@/components/ui/ContributorsList"; +import ErrorModal from "@/components/ui/modal/error-modal"; +import api from "@/lib/api"; +import { Art } from "@/types/art"; + +interface ArtworkPageProps { + artwork?: Art; + error?: string; +} + +export default function ArtworkPage({ artwork, error }: ArtworkPageProps) { + const router = useRouter(); + + if (error) { + return router.back()} />; + } + + if (!artwork) { + return ( + router.push("/artwork")} + /> + ); + } + + return ( +
+ + + +
+
+ Artwork image +
+ +
+

+ {artwork.name} +

+

{artwork.description}

+ + +
+
+
+ ); +} + +type FeaturedResponse = Art[] | { results: Art[] }; + +export const getServerSideProps: GetServerSideProps = async ( + context, +) => { + const { id } = context.params as { id: string }; + + try { + // We only have this endpoint, so reuse it and pick the item by art_id + const res = await api.get("arts/featured"); + const data = res.data; + + const list: Art[] = Array.isArray(data) ? data : (data?.results ?? []); + const artwork = list.find((a) => String(a.art_id) === String(id)); + + if (!artwork) return { notFound: true }; + + return { props: { artwork } }; + } catch (err: unknown) { + return { + props: { error: (err as Error).message || "Failed to load artwork." }, + }; + } +}; diff --git a/client/src/pages/artwork/index.tsx b/client/src/pages/artwork/index.tsx new file mode 100644 index 0000000..50429a7 --- /dev/null +++ b/client/src/pages/artwork/index.tsx @@ -0,0 +1,180 @@ +import { GetServerSideProps } from "next"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; + +import ImageCard from "@/components/ui/ImageCard"; +import ErrorModal from "@/components/ui/modal/error-modal"; +import { generateMockArtworks } from "@/hooks/use-artwork-data"; +import api from "@/lib/api"; +import { Art } from "@/types/art"; + +export interface PageResult { + count: number; + next: string; + previous: string; + results: T[]; +} + +interface ArtworksPageProps { + artworks?: PageResult; + error?: string; +} + +function hasResultsArray(value: unknown): value is { results: T[] } { + if (typeof value !== "object" || value === null) return false; + + const v = value as Record; + return Array.isArray(v.results); +} + +const PLACEHOLDER_ICON = ( +
+ Placeholder icon +
+); + +function renderArtworkCard(artwork: Art) { + return ( + +
+

+ {artwork.name} +

+

+ {artwork.source_game_name ? ( + <> + from{" "} + e.stopPropagation()} + > + {artwork.source_game_name} + + + ) : ( + "No associated game" + )} +

+

+ {artwork.description || "No description available."} +

+
+ + {artwork.contributors.length > 0 && ( +
+

+ Contributors +

+
+ {artwork.contributors.map((contributor) => ( +
+ e.stopPropagation()} + > + {contributor.member_name} + +
+ ))} +
+
+ )} + + e.stopPropagation()} + > + View full details + + + } + /> + ); +} + +export default function ArtworksPage({ artworks, error }: ArtworksPageProps) { + const router = useRouter(); + if (error && !artworks?.results?.length) { + return router.back()} />; + } + + const featuredArtworks = artworks?.results?.slice(0, 3) ?? []; + + return ( +
+
+

+ Featured Artwork +

+ +
+ {featuredArtworks.map(renderArtworkCard)} +
+
+
+ ); +} + +export const getServerSideProps: GetServerSideProps< + ArtworksPageProps +> = async () => { + try { + const res = await api.get("arts/featured"); + const data = res.data as unknown; + + // Accept either: PageResult OR Art[] + const results: Art[] | null = Array.isArray(data) + ? (data as Art[]) + : hasResultsArray(data) + ? data.results + : null; + + // If API didn't throw but returned an unexpected shape, trigger fallback + if (!results) throw new Error("Invalid arts/featured response shape"); + + return { + props: { + artworks: { + results, + count: results.length, + next: "", + previous: "", + }, + }, + }; + } catch (err) { + // Fallback to mock data on any error (network, 500, invalid shape, etc.) + const mockArtworks = generateMockArtworks(3); + return { + props: { + artworks: { + results: mockArtworks, + count: mockArtworks.length, + next: "", + previous: "", + }, + error: err instanceof Error ? err.message : undefined, + }, + }; + } +}; diff --git a/client/src/types/art-contributor.ts b/client/src/types/art-contributor.ts new file mode 100644 index 0000000..27e97b4 --- /dev/null +++ b/client/src/types/art-contributor.ts @@ -0,0 +1,6 @@ +export interface ArtContributor { + id: number; + member_id: number; + member_name: string; + role: string; +} diff --git a/client/src/types/art.ts b/client/src/types/art.ts new file mode 100644 index 0000000..38436e8 --- /dev/null +++ b/client/src/types/art.ts @@ -0,0 +1,14 @@ +import { ArtContributor } from "./art-contributor"; + +export interface Art { + art_id: number; + name: string; + description: string; + media: string; + active: boolean; + source_game_id: number | null; + source_game_name: string | null; + contributors: ArtContributor[]; + showcase_description: string; + isMock?: boolean; +} diff --git a/server/game_dev/admin.py b/server/game_dev/admin.py index 496d112..03c3d8f 100644 --- a/server/game_dev/admin.py +++ b/server/game_dev/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Member, Game, Event, GameContributor, GameShowcase, Committee, SocialMedia +from .models import Art, ArtContributor, ArtShowcase, Member, Game, Event, GameContributor, GameShowcase, Committee, SocialMedia class SocialMediaInline(admin.TabularInline): @@ -37,9 +37,20 @@ class CommitteeAdmin(admin.ModelAdmin): raw_id_fields = ["id"] +class ArtContributorInline(admin.TabularInline): + model = ArtContributor + extra = 1 + + +class ArtAdmin(admin.ModelAdmin): + inlines = [ArtContributorInline] + + admin.site.register(Member, MemberAdmin) admin.site.register(Event, EventAdmin) admin.site.register(Game, GamesAdmin) admin.site.register(GameContributor, GameContributorAdmin) admin.site.register(GameShowcase, GameShowcaseAdmin) +admin.site.register(Art, ArtAdmin) +admin.site.register(ArtShowcase) admin.site.register(Committee, CommitteeAdmin) diff --git a/server/game_dev/migrations/0030_art_artcontributor_artshowcase.py b/server/game_dev/migrations/0030_art_artcontributor_artshowcase.py new file mode 100644 index 0000000..83aa505 --- /dev/null +++ b/server/game_dev/migrations/0030_art_artcontributor_artshowcase.py @@ -0,0 +1,50 @@ +# Generated by Django 5.1.15 on 2026-03-01 06:47 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('game_dev', '0029_rename_cover_image_event_coverimage_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Art', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('description', models.CharField(max_length=200)), + ('media', models.ImageField(upload_to='art/')), + ('active', models.BooleanField(default=True)), + ('source_game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='game_artwork', to='game_dev.game')), + ], + ), + migrations.CreateModel( + name='ArtContributor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(max_length=100)), + ('art', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contributors', to='game_dev.art')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='art_contributions', to='game_dev.member')), + ], + options={ + 'verbose_name': 'Art Contributor', + 'verbose_name_plural': 'Art Contributors', + 'constraints': [models.UniqueConstraint(fields=('art', 'member'), name='unique_art_member')], + }, + ), + migrations.CreateModel( + name='ArtShowcase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=200)), + ('art', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='showcase', to='game_dev.art')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('art',), name='unique_artshowcase_per_art', violation_error_message='Each art piece can only have one showcase.')], + }, + ), + ] diff --git a/server/game_dev/models.py b/server/game_dev/models.py index dece53c..471b328 100644 --- a/server/game_dev/models.py +++ b/server/game_dev/models.py @@ -90,6 +90,49 @@ def __str__(self): return f"{self.game.name}" +class Art(models.Model): + name = models.CharField(null=False, max_length=200) + description = models.CharField(max_length=200,) + source_game = models.ForeignKey('Game', on_delete=models.CASCADE, related_name='game_artwork') + media = models.ImageField(upload_to='art/', null=False) + active = models.BooleanField(default=True) + + def __str__(self): + return str(self.name) + + +class ArtContributor(models.Model): + art = models.ForeignKey('Art', on_delete=models.CASCADE, related_name='contributors') + member = models.ForeignKey('Member', on_delete=models.CASCADE, related_name='art_contributions') + role = models.CharField(max_length=100) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['art', 'member'], name='unique_art_member') + ] + verbose_name = 'Art Contributor' + verbose_name_plural = 'Art Contributors' + + def __str__(self): + return f"{self.member.name} - {self.art.name} ({self.role})" + + +class ArtShowcase(models.Model): + description = models.CharField(max_length=200) + art = models.ForeignKey(Art, on_delete=models.CASCADE, related_name='showcase') + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['art'], + name='unique_artshowcase_per_art', + violation_error_message='Each art piece can only have one showcase.') + ] + + def __str__(self): + return f"ArtShowcase[Art={str(self.art.name)}, Description={self.description}]" + + class SocialMedia(models.Model): link = models.URLField(max_length=2083) member = models.ForeignKey('Member', on_delete=models.CASCADE, related_name='social_media_links') diff --git a/server/game_dev/serializers.py b/server/game_dev/serializers.py index 97a9295..2311efa 100644 --- a/server/game_dev/serializers.py +++ b/server/game_dev/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Event, Game, Member, GameShowcase, GameContributor, SocialMedia +from .models import ArtShowcase, Event, Game, Art, ArtContributor, Member, GameShowcase, GameContributor, SocialMedia class EventSerializer(serializers.ModelSerializer): @@ -136,4 +136,44 @@ class MemberSerializer(serializers.ModelSerializer): class Meta: model = Member - fields = ["name", "profile_picture", "about", "pronouns", "social_media", "pk"] + fields = [ + "name", + "profile_picture", + "about", + "pronouns", + "social_media", + "pk" + ] + + +class ArtContributorSerializer(serializers.ModelSerializer): + member_id = serializers.IntegerField(source='member.id', read_only=True) + member_name = serializers.CharField(source='member.name', read_only=True) + + class Meta: + model = ArtContributor + fields = ['id', 'member_id', 'member_name', 'role'] + + +class ArtSerializer(serializers.ModelSerializer): + art_id = serializers.IntegerField(source='id', read_only=True) + source_game_id = serializers.IntegerField(source='source_game.id', read_only=True) + source_game_name = serializers.CharField(source='source_game.name', read_only=True) + contributors = ArtContributorSerializer(many=True, read_only=True) + showcase_description = serializers.SerializerMethodField() + + class Meta: + model = Art + fields = ['art_id', 'name', 'description', 'media', 'active', 'source_game_id', 'source_game_name', 'contributors', 'showcase_description'] + + def get_showcase_description(self, obj): + showcase = obj.showcase.first() + return showcase.description if showcase else None + + +class ArtShowcaseSerializer(serializers.ModelSerializer): + art_name = serializers.CharField(source='art.name', read_only=True) + + class Meta: + model = ArtShowcase + fields = ['id', 'description', 'art', 'art_name'] diff --git a/server/game_dev/tests.py b/server/game_dev/tests.py index 9b30e70..aca28f8 100644 --- a/server/game_dev/tests.py +++ b/server/game_dev/tests.py @@ -1,5 +1,5 @@ from django.test import TestCase -from .models import Member, Event, Committee +from .models import Member, Event, Committee, Game, Art, ArtContributor, ArtShowcase import datetime from django.core.files.uploadedfile import SimpleUploadedFile from django.utils import timezone @@ -198,3 +198,201 @@ def test_default_is_upcoming(self): def test_invalid_type(self): res = self.client.get(self.url, {"type": "invalid"}) self.assertEqual(res.status_code, 400) + + +class ArtModelTest(TestCase): + def setUp(self): + # Create a game for source_game foreign key + self.game = Game.objects.create( + name="Test Game", + description="A test game", + completion=Game.CompletionStatus.WIP, + hostURL="https://example.com", + ) + + # Create an art piece with media + image_file = SimpleUploadedFile( + "test_art.jpg", + b"dummy art image data", + content_type="image/jpeg", + ) + self.art = Art.objects.create( + name="Test Artwork", + description="A beautiful test artwork", + source_game=self.game, + media=image_file, + ) + + def test_art_creation(self): + try: + Art.objects.get(name="Test Artwork") + except Art.DoesNotExist: + self.fail("Art was not properly created") + + def test_art_is_active_by_default(self): + self.assertTrue(self.art.active) + + def test_media_is_saved_in_correct_folder(self): + self.assertTrue(self.art.media.name.startswith("art/")) + + def test_media_field_not_empty(self): + self.assertIsNotNone(self.art.media) + + def test_source_game_relationship(self): + art = Art.objects.get(pk=self.art.pk) + self.assertEqual(art.source_game, self.game) + + def test_cascade_from_game(self): + # When game is deleted, art should remain (SET_NULL behavior would be ideal, but currently CASCADE) + art_id = self.art.id + self.game.delete() + # Since source_game has CASCADE, the art should be deleted + with self.assertRaises(Art.DoesNotExist): + Art.objects.get(id=art_id) + + +class ArtContributorModelTest(TestCase): + def setUp(self): + # Create member + self.member1 = Member.objects.create( + name="John Artist", + about="A talented artist", + pronouns="He/Him" + ) + self.member2 = Member.objects.create( + name="Jane Designer", + about="A creative designer", + pronouns="She/Her" + ) + + # Create art + image_file = SimpleUploadedFile( + "test_art.jpg", + b"dummy art image data", + content_type="image/jpeg", + ) + self.art = Art.objects.create( + name="Collaborative Artwork", + description="Art with multiple contributors", + media=image_file, + ) + + # Create art contributor + self.art_contributor = ArtContributor.objects.create( + art=self.art, + member=self.member1, + role="Lead Artist" + ) + + def test_art_contributor_creation(self): + try: + ArtContributor.objects.get(art=self.art, member=self.member1) + except ArtContributor.DoesNotExist: + self.fail("ArtContributor was not properly created") + + def test_art_contributor_unique_constraint(self): + # Try to create duplicate art-member pair + with self.assertRaises(IntegrityError): + ArtContributor.objects.create( + art=self.art, + member=self.member1, + role="Another Role" + ) + + def test_multiple_contributors_for_same_art(self): + # Should be able to add different members to same art + ArtContributor.objects.create( + art=self.art, + member=self.member2, + role="Character Designer" + ) + contributors = ArtContributor.objects.filter(art=self.art) + self.assertEqual(contributors.count(), 2) + + def test_cascade_from_art(self): + # When art is deleted, art contributors should be deleted + contributor_id = self.art_contributor.id + self.art.delete() + with self.assertRaises(ArtContributor.DoesNotExist): + ArtContributor.objects.get(id=contributor_id) + + def test_cascade_from_member(self): + # When member is deleted, art contributors should be deleted + contributor_id = self.art_contributor.id + self.member1.delete() + with self.assertRaises(ArtContributor.DoesNotExist): + ArtContributor.objects.get(id=contributor_id) + + def test_art_contributor_role(self): + contributor = ArtContributor.objects.get(pk=self.art_contributor.pk) + self.assertEqual(contributor.role, "Lead Artist") + + +class ArtShowcaseModelTest(TestCase): + def setUp(self): + # Create art pieces + image_file1 = SimpleUploadedFile( + "test_art1.jpg", + b"dummy art image data", + content_type="image/jpeg", + ) + self.art1 = Art.objects.create( + name="Showcased Artwork", + description="This art is showcased", + media=image_file1, + ) + + image_file2 = SimpleUploadedFile( + "test_art2.jpg", + b"dummy art image data 2", + content_type="image/jpeg", + ) + self.art2 = Art.objects.create( + name="Another Artwork", + description="This art is also showcased", + media=image_file2, + ) + + # Create showcase + self.showcase = ArtShowcase.objects.create( + art=self.art1, + description="Featured artwork of the month" + ) + + def test_art_showcase_creation(self): + try: + ArtShowcase.objects.get(art=self.art1) + except ArtShowcase.DoesNotExist: + self.fail("ArtShowcase was not properly created") + + def test_art_showcase_unique_constraint(self): + # Try to create another showcase for the same art + with self.assertRaises(IntegrityError): + ArtShowcase.objects.create( + art=self.art1, + description="Another showcase for same art" + ) + + def test_multiple_showcases_for_different_arts(self): + # Should be able to create showcases for different art pieces + ArtShowcase.objects.create( + art=self.art2, + description="Another featured artwork" + ) + showcases = ArtShowcase.objects.all() + self.assertEqual(showcases.count(), 2) + + def test_cascade_from_art(self): + # When art is deleted, its showcase should be deleted + showcase_id = self.showcase.id + self.art1.delete() + with self.assertRaises(ArtShowcase.DoesNotExist): + ArtShowcase.objects.get(id=showcase_id) + + def test_showcase_description(self): + showcase = ArtShowcase.objects.get(pk=self.showcase.pk) + self.assertEqual(showcase.description, "Featured artwork of the month") + + def test_art_showcase_relationship(self): + showcase = ArtShowcase.objects.get(pk=self.showcase.pk) + self.assertEqual(showcase.art, self.art1) diff --git a/server/game_dev/urls.py b/server/game_dev/urls.py index 7e103bf..014ac21 100644 --- a/server/game_dev/urls.py +++ b/server/game_dev/urls.py @@ -1,10 +1,14 @@ from django.urls import path -from .views import ContributorGamesListAPIView, EventListAPIView, EventDetailAPIView -from .views import GamesDetailAPIView, GameshowcaseAPIView, MemberAPIView, CommitteeAPIView +from .views import (ContributorGamesListAPIView, EventListAPIView, EventDetailAPIView, GamesDetailAPIView, + GameshowcaseAPIView, MemberAPIView, CommitteeAPIView, + FeatureArtAPIView, ArtDetailAPIView) + urlpatterns = [ path("events/", EventListAPIView.as_view(), name="events-list"), path("events//", EventDetailAPIView.as_view()), + path('arts/featured/', FeatureArtAPIView.as_view()), + path('arts//', ArtDetailAPIView.as_view(), name='art-detail'), path("games//", GamesDetailAPIView.as_view()), path("games/contributor//", ContributorGamesListAPIView.as_view()), diff --git a/server/game_dev/views.py b/server/game_dev/views.py index 26f31bc..703be36 100644 --- a/server/game_dev/views.py +++ b/server/game_dev/views.py @@ -1,6 +1,6 @@ from rest_framework import generics -from .serializers import ContributorGameSerializer, GamesSerializer, GameshowcaseSerializer, EventSerializer, MemberSerializer -from .models import Game, GameContributor, GameShowcase, Event, Member, Committee +from .serializers import ContributorGameSerializer, GamesSerializer, GameshowcaseSerializer, EventSerializer, MemberSerializer, ArtSerializer +from .models import Game, GameContributor, GameShowcase, Event, Member, Committee, Art from django.utils import timezone from rest_framework.views import APIView from rest_framework.response import Response @@ -116,3 +116,24 @@ def get_queryset(self): except Committee.DoesNotExist: outputList.append(placeholderMember) return outputList + + +class ArtDetailAPIView(generics.RetrieveAPIView): + """ + GET /api/artworks// + """ + serializer_class = ArtSerializer + lookup_url_kwarg = "id" + + def get_queryset(self): + return Art.objects.filter(id=self.kwargs["id"]) + + +class FeatureArtAPIView(generics.ListAPIView): + """ + GET /api/arts/featured/ + """ + serializer_class = ArtSerializer + + def get_queryset(self): + return Art.objects.filter(showcase__isnull=False)