Build Brief
React (TypeScript) + Django REST Framework 8 hours Intermediate

What You Are Building

A workspace announcement board. Admins post announcements to the whole workspace. All members can view them. Announcements can be pinned to the top, set to expire on a date, and deleted. The list is paginated and shows pinned announcements first.

Roles & Access
  • MemberCan browse the announcement feed and read active posts, but cannot access create, edit, pin, or delete actions.
  • Admin / OwnerCreate announcements, edit them, pin or unpin them, and remove them when they are no longer needed.
Core Flows

How this product should work

This page should read like a real internal announcement system, with clear member visibility, strong admin controls, and feed ordering that changes correctly when items are pinned or expire.

Read the feed

Members load a paginated announcement list where pinned announcements surface first and expired items are excluded.

Create and edit

Admins use one shared modal for create and edit so title, body, pin, and expiry logic stay consistent.

Pin and ordering

Pin toggles update the backend and immediately change the visual order so priority announcements stay at the top.

Delete and expire

Delete requires confirmation, while expiry changes what members can see without depending on frontend guesses.

Features to Build

# Feature Est. Time
1 Django: Announcement model + migration 45m
2 Django: AnnouncementSerializer + AnnouncementWriteSerializer 30m
3 Django: AnnouncementService (create, pin, delete) 30m
4 Django: DRF views + URLs + permission class 1h
5 React: Announcement list page (skeleton, empty, error) 1h 30m
6 React: Create/Edit announcement modal (shared component) 1h 30m
7 React: Pin toggle + delete (admin only) 30m
8 Permissions + all states 30m

1. Django Model

Announcement

class Announcement(models.Model):
    workspace   = models.ForeignKey('Workspace', on_delete=models.CASCADE, related_name='announcements')
    title       = models.CharField(max_length=200)
    body        = models.TextField()
    is_pinned   = models.BooleanField(default=False)
    expires_at  = models.DateTimeField(null=True, blank=True)
    created_by  = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
                                    null=True, related_name='announcements')
    created_at  = models.DateTimeField(auto_now_add=True)
    updated_at  = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-is_pinned', '-created_at']
        verbose_name = 'Announcement'
        verbose_name_plural = 'Announcements'

    def __str__(self):
        return f"{self.workspace.name} / {self.title}"

Rules:

  • Pinned announcements always appear at the top (ordering by -is_pinned first)
  • expires_at is optional — if set, expired announcements are excluded from member views

Run checklist: Django Model


2. Serializers

AnnouncementSerializer (read — response)

fields = ['id', 'title', 'body', 'is_pinned', 'expires_at',
          'created_by', 'created_at', 'updated_at', 'is_expired']
read_only_fields = ['id', 'created_by', 'created_at', 'updated_at']

# is_expired: SerializerMethodField — returns True if expires_at < now()

AnnouncementWriteSerializer (write — create/update)

fields = ['title', 'body', 'is_pinned', 'expires_at']

# Validation:
# - title: required, max 200 chars
# - body: required
# - expires_at: if provided, must be in the future

Run checklist: Django Form / Serializer


3. AnnouncementService

# services.py

class AnnouncementService:

    @staticmethod
    def create_announcement(workspace, title, body, created_by,
                            is_pinned=False, expires_at=None):
        """
        Creates a new announcement.
        Raises ValidationError if title or body is blank.
        """

    @staticmethod
    def toggle_pin(announcement):
        """
        Toggles is_pinned on the announcement and saves.
        Returns the updated announcement.
        """

    @staticmethod
    def delete_announcement(announcement):
        """
        Deletes the announcement.
        """

No business logic in the view. Views call service methods.


4. DRF Views & URLs

Permission Class: IsWorkspaceMember

# permissions.py
class IsWorkspaceMember(BasePermission):
    def has_permission(self, request, view):
        workspace_id = view.kwargs.get('workspace_id')
        return WorkspaceMember.objects.filter(
            workspace_id=workspace_id, user=request.user, status='active'
        ).exists()

AnnouncementListCreateView

GET  /api/v1/workspaces/{workspace_id}/announcements/
  — Returns active (non-expired) announcements, pinned first
  — Pagination: 20 per page
  — For admins: include expired announcements (add ?include_expired=true)

POST /api/v1/workspaces/{workspace_id}/announcements/
  — Admin only
  — Calls AnnouncementService.create_announcement()
  — Returns 201 with created announcement

AnnouncementDetailView

GET    /api/v1/workspaces/{workspace_id}/announcements/{id}/
PATCH  /api/v1/workspaces/{workspace_id}/announcements/{id}/  — admin only
DELETE /api/v1/workspaces/{workspace_id}/announcements/{id}/  — admin only, returns 204

AnnouncementPinView

POST /api/v1/workspaces/{workspace_id}/announcements/{id}/pin/
  — Admin only
  — Calls AnnouncementService.toggle_pin()
  — Returns 200 with updated announcement

URL names (kebab-case)

name='announcement-list'
name='announcement-detail'
name='announcement-pin'

Run checklist: DRF API View


5. React: Announcement List Page

URL: /workspaces/{id}/announcements

[ ] Pinned announcements shown at top with a 📌 pin indicator
[ ] Each announcement card shows: title, body preview (3 lines, truncated), created by, date
[ ] Clicking a card expands it to show full body
[ ] Admin actions on each card: Pin/Unpin toggle · Edit · Delete — hidden for member role
[ ] "New Announcement" button at top right — admin only
[ ] Pagination: 20 per page ("Load more" style acceptable)

TypeScript:
  interface Announcement {
    id: number;
    title: string;
    body: string;
    is_pinned: boolean;
    is_expired: boolean;
    expires_at: string | null;
    created_by: { id: number; name: string };
    created_at: string;
  }

Custom hook: useAnnouncements(workspaceId) → { data, isLoading, error, refetch }

STATES:
[ ] Loading: 3 skeleton cards (matching card shape)
[ ] Empty: "No announcements yet." + "Post your first announcement" (admin only)
[ ] Error: "Could not load announcements." + retry button

Run checklist: React Page Build · Loading States & Skeletons


6. Create / Edit Announcement Modal

One shared AnnouncementModal component.

Props

interface AnnouncementModalProps {
  mode: 'add' | 'edit';
  announcement?: Announcement;  // provided in edit mode
  workspaceId: number;
  onSuccess: () => void;
  onClose: () => void;
}

Form Fields

Field Type Rules
Title text input Required, max 200 chars
Body textarea Required
Pin this announcement checkbox Default: unchecked
Expires on date-time picker Optional, must be future

Behavior

ADD MODE: title "New Announcement", submit "Post Announcement"
EDIT MODE: title "Edit Announcement", pre-filled, submit "Save Changes"

[ ] Loading state on submit button, fields disabled
[ ] Validation errors shown below each field
[ ] API error shown in a banner above submit (not toast)
[ ] On success: close modal, call onSuccess (triggers list refetch), "Announcement posted." toast
[ ] Escape + X close — prompt "Discard changes?" if dirty

Run checklist: Add/Edit Consistency · Modals & Dialogs


7. Pin & Delete

Pin Toggle






Delete



Title: "Delete Announcement?"

Body: "'[Title]' will be permanently deleted."

Buttons: Cancel · "Delete" (red)



Run checklist: Delete & Destructive Actions · Notifications & Toasts


Error Handling







Run checklist: Error Handling


Permissions Summary

| Action | Member | Admin | Owner |
|--------|--------|-------|-------|
| View announcements | ✓ | ✓ | ✓ |
| Create announcement | ✗ | ✓ | ✓ |
| Edit announcement | ✗ | ✓ | ✓ |
| Pin announcement | ✗ | ✓ | ✓ |
| Delete announcement | ✗ | ✓ | ✓ |

[ ] New Announcement button hidden for member role
[ ] Edit, Pin, Delete actions hidden per card for member role
[ ] Django: permission class checks workspace membership AND role for write operations

What You Should NOT Do

× Put create/pin/delete logic in the DRF view — delegate to AnnouncementService
× Use fields = '__all__' in any serializer
× Return 200 for a newly created announcement — use 201
× Show Create/Edit/Delete to member-role React users
× Forget to check if expires_at is in the future during validation
× Show expired announcements to members (filter on backend, not frontend)
× Let the Delete work without a confirmation dialog

Checklists to Run (in order)













Done When