Members load a paginated announcement list where pinned announcements surface first and expired items are excluded.
Project — Announcement Board
Build an announcement board with pin toggle, expiry, optimistic UI, and role-based create/edit/delete.
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.
- 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.
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.
Admins use one shared modal for create and edit so title, body, pin, and expiry logic stay consistent.
Pin toggles update the backend and immediately change the visual order so priority announcements stay at the top.
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_pinnedfirst) expires_atis 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
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