diff --git a/ b/ new file mode 100644 index 000000000..e69de29bb diff --git a/dmoj/urls.py b/dmoj/urls.py index 20172f12e..494df91a5 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -280,6 +280,7 @@ def paged_list_view(view, name): path('organizations/', organization.OrganizationList.as_view(), name='organization_list'), path('organizations/create', organization.CreateOrganization.as_view(), name='organization_create'), + path('organization/id/', organization.OrganizationHomeById.as_view(), name='organization_home_by_id'), path('organization/-', lambda _, pk, suffix: HttpResponsePermanentRedirect('/organization/%s' % suffix)), path('organization/', include([ diff --git a/judge/admin/organization.py b/judge/admin/organization.py index e94a2a2ee..5628092be 100644 --- a/judge/admin/organization.py +++ b/judge/admin/organization.py @@ -21,12 +21,19 @@ class OrganizationAdmin(VersionAdmin): readonly_fields = ('creation_date', 'current_consumed_credit') fields = ('name', 'slug', 'short_name', 'is_open', 'is_unlisted', 'paid_credit', 'current_consumed_credit', 'about', 'logo_override_image', 'slots', 'creation_date', 'admins') - list_display = ('name', 'short_name', 'is_open', 'is_unlisted', 'slots', 'show_public') + list_display = ('id', 'name', 'short_name', 'is_open', 'is_unlisted', 'slots', 'show_public') prepopulated_fields = {'slug': ('name',)} actions = ('recalculate_points',) actions_on_top = True actions_on_bottom = True form = OrganizationForm + search_fields = ('name', 'short_name') + + def id(self, obj): + return format_html('{}', obj.id, obj.id) + id.short_description = 'ID' + id.admin_order_field = 'id' + list_filter = ('is_open', 'is_unlisted') @admin.display(description='') def show_public(self, obj): diff --git a/judge/views/organization.py b/judge/views/organization.py index 81c3fbc87..2ce66b421 100644 --- a/judge/views/organization.py +++ b/judge/views/organization.py @@ -88,6 +88,49 @@ def is_in_organization_subdomain(self): # Use this mixin to mark a view is public for all users, including non-members +class OrganizationByIdMixin(object): + model = Organization + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['organization'] = self.organization + context['logo_override_image'] = self.organization.logo_override_image + context['meta_description'] = self.organization.about[:settings.DESCRIPTION_MAX_LENGTH] + return context + + @cached_property + def organization(self): + return get_object_or_404(Organization, id=self.kwargs['pk']) + + def dispatch(self, request, *args, **kwargs): + if 'pk' not in kwargs: + raise ImproperlyConfigured('Must pass an id') + + try: + self.object = self.organization + + # block the user from viewing other orgs in the subdomain + if self.is_in_organization_subdomain() and self.organization.pk != self.request.organization.pk: + return generic_message(request, _('Cannot view other organizations'), + _('You cannot view other organizations'), status=403) + + return super(OrganizationByIdMixin, self).dispatch(request, *args, **kwargs) + except Http404: + pk = kwargs.get('pk', None) + return generic_message(request, _('No such organization'), + _('Could not find an organization with ID "%s".') % pk) + + def can_edit_organization(self, org=None): + if org is None: + org = self.organization + if not self.request.user.is_authenticated: + return False + return org.is_admin(self.request.profile) or self.request.user.has_perm('judge.edit_all_organization') + + def is_in_organization_subdomain(self): + return hasattr(self.request, 'organization') + + class PublicOrganizationMixin(OrganizationMixin): pass @@ -96,13 +139,18 @@ class PublicOrganizationMixin(OrganizationMixin): class PrivateOrganizationMixin(OrganizationMixin): # If the user has at least one of the following permissions, # they can access the private data even if they are not in the org - permission_bypass = [] + permission_bypass = ['judge.edit_organization'] # Override this method to customize the permission check def can_access_this_view(self): if self.request.user.is_authenticated: + # Allow superusers and staff to access any organization + if self.request.user.is_superuser or self.request.user.is_staff: + return True + # Allow organization members if self.request.profile in self.organization: return True + # Allow users with specific permissions if any(self.request.user.has_perm(perm) for perm in self.permission_bypass): return True return False @@ -146,7 +194,14 @@ class OrganizationList(TitleMixin, ListView): title = gettext_lazy('Organizations') def get_queryset(self): - return Organization.objects.filter(is_unlisted=False) + queryset = Organization.objects.filter(is_unlisted=False) + search_query = self.request.GET.get('search', '').strip() + if search_query: + queryset = queryset.filter( + Q(name__icontains=search_query) | + Q(short_name__icontains=search_query) + ) + return queryset class OrganizationUsers(QueryStringSortMixin, DiggPaginatorMixin, BaseOrganizationListView): @@ -471,6 +526,38 @@ def post(self, request, *args, **kwargs): # using PublicOrganizationMixin to allow user to view org's public information # like name, request join org, ... # However, they cannot see the organization private blog +class OrganizationHomeById(TitleMixin, OrganizationByIdMixin, PostListBase): + template_name = 'organization/home.html' + + def get_title(self): + return self.organization.name + + def get_queryset(self): + queryset = BlogPost.objects.filter(organization=self.organization) + + if not self.request.user.has_perm('judge.edit_all_post'): + if not self.can_edit_organization(): + if self.request.profile in self.organization: + # Normal user can only view public posts + queryset = queryset.filter(publish_on__lte=timezone.now(), visible=True) + else: + # User cannot view organization blog + # if they are not in the org + # even if the org is public + queryset = BlogPost.objects.none() + else: + # Org admin can view public posts & their own posts + queryset = queryset.filter(Q(visible=True) | Q(authors=self.request.profile)) + + if self.request.user.is_authenticated: + profile = self.request.profile + queryset = queryset.annotate( + my_vote=FilteredRelation('votes', condition=Q(votes__voter_id=profile.id)), + ).annotate(vote_score=Coalesce(F('my_vote__score'), Value(0))) + + return queryset.order_by('-sticky', '-publish_on').prefetch_related('authors__user') + + class OrganizationHome(TitleMixin, PublicOrganizationMixin, PostListBase): template_name = 'organization/home.html' diff --git a/package-lock.json b/package-lock.json index 1ac05f9f3..2cac3f574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,12 @@ "dependencies": { "@commander-js/extra-typings": "11.0.0", "commander": "11.0.0", - "ws": "8.14.0" + "ws": "^8.18.3" }, "devDependencies": { "@types/ws": "8.5.5", "autoprefixer": "10.4.15", - "postcss": "8.4.29", + "postcss": "^8.5.6", "postcss-cli": "10.1.0", "prettier": "3.0.3", "sass": "1.66.1" @@ -161,12 +161,13 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -359,10 +360,11 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -543,6 +545,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -578,12 +581,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -591,9 +595,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -601,6 +605,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -642,10 +647,11 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -669,9 +675,9 @@ } }, "node_modules/postcss": { - "version": "8.4.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz", - "integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -687,10 +693,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -917,10 +924,11 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -962,6 +970,7 @@ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -1026,9 +1035,10 @@ } }, "node_modules/ws": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.0.tgz", - "integrity": "sha512-WR0RJE9Ehsio6U4TuM+LmunEsjQ5ncHlw4sn9ihD6RoJKZrVyH9FWV3dmnwu8B2aNib1OvG2X6adUCyFpQyWcg==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 917adc13b..442557ea2 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "dependencies": { "@commander-js/extra-typings": "11.0.0", "commander": "11.0.0", - "ws": "8.14.0" + "ws": "^8.18.3" }, "devDependencies": { "@types/ws": "8.5.5", "autoprefixer": "10.4.15", - "postcss": "8.4.29", + "postcss": "^8.5.6", "postcss-cli": "10.1.0", "prettier": "3.0.3", "sass": "1.66.1" diff --git a/resources/organization.scss b/resources/organization.scss new file mode 100644 index 000000000..178c796b4 --- /dev/null +++ b/resources/organization.scss @@ -0,0 +1,40 @@ +.search-container { + margin: 1em 0; + padding: 1em; + background: #fff; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.search-form { + display: flex; + max-width: 600px; + margin: 0 auto; +} + +.search-input { + flex: 1; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px 0 0 4px; + font-size: 1em; + outline: none; +} + +.search-input:focus { + border-color: #5b80b2; +} + +.search-submit { + padding: 8px 16px; + background: #5b80b2; + color: white; + border: 1px solid #5b80b2; + border-radius: 0 4px 4px 0; + cursor: pointer; + transition: background-color 0.2s; +} + +.search-submit:hover { + background: #466a9f; +} \ No newline at end of file diff --git a/templates/organization/list.html b/templates/organization/list.html index 47b8567a0..81a2cf141 100644 --- a/templates/organization/list.html +++ b/templates/organization/list.html @@ -1,4 +1,8 @@ {% extends "base.html" %} +{% block media %} + +{% endblock %} + {% block js_media %}