Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

move default tag setup to BE #642

Merged
merged 9 commits into from
Dec 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,24 @@
import re


class PuzzleTagSerializer(serializers.ModelSerializer):
class Meta:
model = PuzzleTag
fields = ("id", "name", "color", "is_meta", "is_high_pri", "is_low_pri")

read_only_fields = ("id", "is_meta", "is_high_pri", "is_low_pri")


class HuntSerializer(serializers.ModelSerializer):
has_drive = serializers.SerializerMethodField()
puzzle_tags = PuzzleTagSerializer(required=False, many=True)

def get_has_drive(self, obj):
return bool(obj.settings.google_drive_human_url)

class Meta:
model = Hunt
fields = ("id", "name", "active", "url", "has_drive")
fields = ("id", "name", "active", "url", "has_drive", "puzzle_tags")


class CurrentHuntDefault:
Expand Down Expand Up @@ -89,14 +98,6 @@ class Meta:
)


class PuzzleTagSerializer(serializers.ModelSerializer):
class Meta:
model = PuzzleTag
fields = ("id", "name", "color", "is_meta")

read_only_fields = ("id", "is_meta")


class PuzzleSerializer(serializers.ModelSerializer):
chat_room = ChatRoomSerializer(required=False)
tags = PuzzleTagSerializer(required=False, many=True)
Expand Down
1 change: 1 addition & 0 deletions api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def test_get_hunt(self):
"url": self._hunt.url,
"active": self._hunt.active,
"has_drive": bool(self._hunt.settings.google_drive_human_url),
"puzzle_tags": [],
},
)

Expand Down
2 changes: 1 addition & 1 deletion api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ def destroy(self, request, pk=None, **kwargs):
puzzle.tags.remove(tag)

# clear db of dangling tags
if not tag.puzzles.exists():
if not tag.is_default and not tag.puzzles.exists():
tag.delete()

if puzzle.chat_room:
Expand Down
4 changes: 4 additions & 0 deletions hunts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class HuntForm(forms.ModelForm):
required=False,
)

populate_tags = forms.BooleanField(
label="Populate hunt with default tags?", initial=True, required=False
)

def clean(self):
data = super().clean()

Expand Down
5 changes: 3 additions & 2 deletions hunts/src/EditPuzzleTagsModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { useDispatch, useSelector } from "react-redux";
import Modal from "react-bootstrap/Modal";
import Button from "react-bootstrap/Button";
import Form from "react-bootstrap/Form";
import { addPuzzleTag, selectAllTags } from "./puzzlesSlice";
import { addPuzzleTag } from "./puzzlesSlice";
import { selectHuntTags } from "./huntSlice";
import { DEFAULT_TAG_COLOR, SELECTABLE_TAG_COLORS } from "./constants";
import { hideModal } from "./modalSlice";
import TagPill from "./TagPill";
import EditableTagList from "./EditableTagList";

function EditPuzzleTagsModal({ puzzleId, puzzleName }) {
const allTags = useSelector(selectAllTags);
const allTags = useSelector(selectHuntTags);
const [newTagName, setNewTagName] = React.useState("");
const [newTagColor, setNewTagColor] = React.useState(DEFAULT_TAG_COLOR);
const dispatch = useDispatch();
Expand Down
47 changes: 0 additions & 47 deletions hunts/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,6 @@ export const SELECTABLE_TAG_COLORS = [
{ color: "primary", display: "Blue" },
{ color: "light", display: "White" },
];
export const HIGH_PRIORITY_TAG = "High priority";
export const LOW_PRIORITY_TAG = "Low priority";

// TODO(#527): Store these in the backend and read them from an API call
export const DEFAULT_TAGS = [
// These tags have special effects, synchronize their names with the PuzzleTag model in python
{ name: HIGH_PRIORITY_TAG, color: "danger" },
{ name: LOW_PRIORITY_TAG, color: "warning" },
{ name: "Backsolved", color: "success" },
{ name: "Slog", color: "secondary" },

{ name: "Grid logic", color: "light" },
{ name: "Non-grid logic", color: "light" },

{ name: "Crossword", color: "primary" },
{ name: "Cryptics", color: "primary" },
{ name: "Wordplay", color: "primary" },

{ name: "Media manipulation", color: "light" },
{ name: "Programming", color: "light" },

{ name: "Art ID", color: "primary" },
{ name: "Bio", color: "primary" },
{ name: "Chem", color: "primary" },
{ name: "Foreign languages", color: "primary" },
{ name: "Geography", color: "primary" },
{ name: "Literature", color: "primary" },
{ name: "Math", color: "primary" },
{ name: "Physics", color: "primary" },

{ name: "Anime", color: "light" },
{ name: "Board games", color: "light" },
// older pop culture
{ name: "Boomer", color: "light" },
{ name: "Knitting", color: "light" },
{ name: "Movies", color: "light" },
{ name: "Music ID", color: "light" },
{ name: "Sports", color: "light" },
{ name: "TV", color: "light" },
{ name: "Video games", color: "light" },
// newer pop culture
{ name: "Zoomer", color: "light" },

{ name: "MIT", color: "primary" },
{ name: "Printing", color: "primary" },
{ name: "Teamwork", color: "primary" },
];

export const SHEET_REDIRECT_BASE = "/puzzles/s";
export const CHAT_PLATFORM = "discord";
6 changes: 6 additions & 0 deletions hunts/src/huntSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ export const huntSlice = createSlice({
id: null,
name: null,
has_drive: false,
puzzle_tags: [],
},
reducers: {},
extraReducers: {
[fetchHunt.fulfilled]: (state, action) => {
state.id = action.payload.id;
state.name = action.payload.name;
state.has_drive = action.payload.has_drive;
state.puzzle_tags = action.payload.puzzle_tags;
},
},
});
Expand All @@ -34,3 +36,7 @@ export default huntSlice.reducer;
const selectHunt = (state) => state.hunt;

export const selectHuntId = createSelector([selectHunt], (hunt) => hunt.id);
export const selectHuntTags = createSelector(
[selectHunt],
(hunt) => hunt.puzzle_tags
);
44 changes: 2 additions & 42 deletions hunts/src/puzzlesSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
} from "@reduxjs/toolkit";
import { selectHuntId } from "./huntSlice";
import api from "./api";
import { DEFAULT_TAGS, HIGH_PRIORITY_TAG, LOW_PRIORITY_TAG } from "./constants";

export const addPuzzle = createAsyncThunk(
"puzzles/addPuzzle",
Expand Down Expand Up @@ -97,9 +96,9 @@ function puzzleComparator(a, b) {
}
// High-priority before untagged before low-priority
function priority(row) {
if (row.tags.some((x) => x.name === HIGH_PRIORITY_TAG)) {
if (row.tags.some((x) => x.is_high_pri)) {
return 1;
} else if (row.tags.some((x) => x.name === LOW_PRIORITY_TAG)) {
} else if (row.tags.some((x) => x.is_low_pri)) {
return -1;
}
return 0;
Expand Down Expand Up @@ -227,45 +226,6 @@ export const selectPuzzleTableData = createSelector(
}
);

export const selectAllTags = createSelector(
[puzzlesSelectors.selectAll],
(puzzles) => {
// uniqueTags is set of unique Tags objects.
// After a default tag is used at least once, a Tag is created in the DB and this needs to be
// the version of the object in uniqueTags, instead of the dummy one created in the constants file.
// The DB versions are needed to display whether puzzle already has the tag.
// Logic below is to select the DB versions. The set is then split between default and non-default.
const tags = puzzles
.map((puzzle) => puzzle.tags)
.flat()
.concat(DEFAULT_TAGS);
const tagNames = new Set();
const uniqueTags = tags.reduce(
(uniqueTags, tag) =>
tagNames.has(tag.name)
? uniqueTags
: tagNames.add(tag.name) && [...uniqueTags, tag],
[]
);
const defaultTagNames = DEFAULT_TAGS.map((tag) => tag.name);
const defaultTags = uniqueTags.filter((tag) =>
defaultTagNames.includes(tag.name)
);
const customTags = uniqueTags.filter(
(tag) => !defaultTagNames.includes(tag.name)
);
defaultTags.sort(
(a, b) =>
defaultTagNames.indexOf(a.name) - defaultTagNames.indexOf(b.name)
);
customTags.sort(
(a, b) => a.color.localeCompare(b.color) || a.name.localeCompare(b.name)
);

return defaultTags.concat(customTags);
}
);

export const selectNumUnlocked = createSelector(
[puzzlesSelectors.selectAll],
(puzzles) => {
Expand Down
4 changes: 4 additions & 0 deletions hunts/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ <h2>Add New Hunt</h2>
<div class="form-group">
{{ form.end_time }}
</div>
<div class="form-group">
{{ form.populate_tags.label_tag }}
{{ form.populate_tags }}
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endif %}
Expand Down
45 changes: 45 additions & 0 deletions hunts/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .models import Hunt
from .forms import HuntForm
from puzzles.models import Puzzle
from puzzles.puzzle_tag import PuzzleTag
from answers.models import Answer
from .chart_utils import *

Expand Down Expand Up @@ -224,6 +225,7 @@ def setUp(self):
self._user = Puzzler.objects.create_user(
username="test", email="test@ing.com", password="testingpwd"
)
self._test_hunt = Hunt.objects.create(name="fake hunt", url="google.com")
self.client.login(username="test", password="testingpwd")

def tearDown(self):
Expand Down Expand Up @@ -260,3 +262,46 @@ def test_times_end_before_start(self):
self.assertEqual(
form.non_field_errors(), ["End time must be after start time."]
)

def test_default_tag_creation(self):
PuzzleTag.create_default_tags(self._test_hunt)

for (name, color) in PuzzleTag.DEFAULT_TAGS:
self.assertTrue(
PuzzleTag.objects.filter(
hunt=self._test_hunt, name=name, color=color
).exists()
)

def test_unused_default_tag_deletion(self):
PuzzleTag.create_default_tags(self._test_hunt)
PuzzleTag.remove_default_tags(self._test_hunt)

for (name, color) in PuzzleTag.DEFAULT_TAGS:
self.assertFalse(
PuzzleTag.objects.filter(
hunt=self._test_hunt, name=name, color=color
).exists()
)

def test_used_default_tag_deletion(self):
PuzzleTag.create_default_tags(self._test_hunt)
puzzle = Puzzle.objects.create(
name="puzzle",
hunt=self._test_hunt,
url="test.com",
sheet="sheet.com",
is_meta=False,
)

for tag in PuzzleTag.objects.filter(is_default=True):
puzzle.tags.add(tag)

PuzzleTag.remove_default_tags(self._test_hunt)

for (name, color) in PuzzleTag.DEFAULT_TAGS:
self.assertTrue(
PuzzleTag.objects.filter(
hunt=self._test_hunt, name=name, color=color
).exists()
)
27 changes: 20 additions & 7 deletions hunts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.urls import reverse
from django.views import View
from django.views.generic.base import RedirectView
from puzzles.models import PuzzleTag

from .forms import HuntForm, HuntSettingsForm
from .models import Hunt
Expand All @@ -28,13 +29,20 @@ def index(request):
if user.is_staff:
form = HuntForm(request.POST)
if form.is_valid():
hunt = Hunt(
name=form.cleaned_data["name"],
url=form.cleaned_data["url"],
start_time=form.cleaned_data["start_time"],
end_time=form.cleaned_data["end_time"],
)
hunt.save()
with transaction.atomic():
hunt = Hunt(
name=form.cleaned_data["name"],
url=form.cleaned_data["url"],
start_time=form.cleaned_data["start_time"],
end_time=form.cleaned_data["end_time"],
)
hunt.save()

if form.cleaned_data["populate_tags"]:
transaction.on_commit(
lambda: PuzzleTag.create_default_tags(hunt)
)

return redirect(reverse("hunts:edit", kwargs={"hunt_slug": hunt.slug}))
else:
return HttpResponseForbidden()
Expand All @@ -60,6 +68,11 @@ def edit(request, hunt_slug):
hunt_form.save()
settings_form.save()

if hunt_form.cleaned_data["populate_tags"]:
transaction.on_commit(lambda: PuzzleTag.create_default_tags(hunt))
else:
transaction.on_commit(lambda: PuzzleTag.remove_default_tags(hunt))

else:
hunt = Hunt.get_object_or_404(user=request.user, slug=hunt_slug)
hunt_form = HuntForm(instance=hunt)
Expand Down
8 changes: 7 additions & 1 deletion puzzles/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Puzzle
from .models import Puzzle, PuzzleTag
from answers.models import Answer


Expand All @@ -15,4 +15,10 @@ class PuzzleAdmin(admin.ModelAdmin):
]


class PuzzleTagAdmin(admin.ModelAdmin):
list_display = ["hunt", "name", "color", "is_meta"]
list_filter = ["hunt", "is_meta"]


admin.site.register(Puzzle, PuzzleAdmin)
admin.site.register(PuzzleTag, PuzzleTagAdmin)
2 changes: 1 addition & 1 deletion puzzles/migrations/0026_dedup_puzzletag_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
def migrate_to_ci_tag_name(apps, schema_editor):
ci_name_to_tag = dict()

for tag in PuzzleTag.objects.all():
for tag in PuzzleTag.objects.values("name", "puzzles").all():
ci_tag_name = tag.name.lower()
if ci_tag_name in ci_name_to_tag:
# move puzzle with duplicate tag to the one we keep
Expand Down
Loading