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 ? (
+ <>
+
{
+ 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.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 = (
+
+
+
+);
+
+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)