diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..001cc3f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +# https://help.github.com/en/articles/displaying-a-sponsor-button-in-your-repository +custom: https://www.buymeacoffee.com/hapytex diff --git a/.github/workflows/django-user-pinned-ci.yml b/.github/workflows/django-user-pinned-ci.yml new file mode 100644 index 0000000..64c0e89 --- /dev/null +++ b/.github/workflows/django-user-pinned-ci.yml @@ -0,0 +1,166 @@ +name: django-user-pinned CI +on: push +jobs: + black: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: psf/black@stable + with: + options: "--check" + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] + steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - run: sudo apt install python3-django + - run: pip install Django + - run: django-admin startproject testproject + - name: checkout code + uses: actions/checkout@v2.3.1 + with: + path: 'testproject_temp' + - run: "mv testproject_temp/* testproject/" + - run: pip install -r requirements.txt + working-directory: 'testproject' + - run: python manage.py test --settings=docs.source.settings + working-directory: 'testproject' + + no-makemigrations: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] + steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - run: sudo apt install python3-django + - run: pip install Django + - run: django-admin startproject testproject + - name: checkout code + uses: actions/checkout@v2.3.1 + with: + path: 'testproject_temp' + - run: "mv testproject_temp/* testproject/" + - run: pip install -r requirements.txt + working-directory: 'testproject' + - run: python manage.py makemigrations --dry-run --settings=docs.source.settings + working-directory: 'testproject' + shell: bash + + no-makemessages: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] + locale: [nl] + steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - run: sudo apt install gettext python3-django + - run: pip install Django + - run: django-admin startproject testproject + - name: checkout code + uses: actions/checkout@v2.3.1 + with: + path: 'testproject_temp' + - run: | + shopt -s dotglob + mv testproject_temp/* testproject/ + - run: pip install -r requirements.txt + working-directory: 'testproject' + - run: python manage.py makemessages --locale=${{ matrix.locale }} --settings=docs.source.settings + working-directory: 'testproject' + - run: git diff --ignore-matching-lines='^"POT-Creation-Date:' --exit-code + working-directory: 'testproject' + + no-compilemessages: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] + steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - run: sudo apt install gettext python3-django + - run: pip install Django + - run: django-admin startproject testproject + - name: checkout code + uses: actions/checkout@v2.3.1 + with: + path: 'testproject_temp' + - run: | + shopt -s dotglob + mv testproject_temp/* testproject/ + - run: pip install -r requirements.txt + working-directory: 'testproject' + - run: python manage.py compilemessages --settings=docs.source.settings + working-directory: 'testproject' + - run: git diff --exit-code + working-directory: 'testproject' + + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] + steps: + - name: checkout code + uses: actions/checkout@v2.3.1 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - run: | + pip install setuptools>=38.6.0 twine>=1.11.0 wheel>=0.31.0 setuptools_scm>=6.2 + python -m setuptools_scm + python setup.py sdist bdist_wheel + + test-publish: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') + needs: [black, build, test, no-makemigrations, no-makemessages, no-compilemessages] + steps: + - name: checkout code + uses: actions/checkout@v2.3.1 + - name: Set up Python + uses: actions/setup-python@v4 + - run: | + pip install setuptools>=38.6.0 twine>=1.11.0 wheel>=0.31.0 setuptools_scm>=6.2 + python -m setuptools_scm + python setup.py sdist bdist_wheel + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TEST_PYPI_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + + publish: + runs-on: ubuntu-latest + needs: [test-publish] + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: checkout code + uses: actions/checkout@v2.3.1 + - name: Set up Python + uses: actions/setup-python@v4 + - run: | + pip install setuptools>=38.6.0 twine>=1.11.0 wheel>=0.31.0 setuptools_scm>=6.2 + python -m setuptools_scm + python setup.py sdist bdist_wheel + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c241e96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +*.log +*.pot +*.pyc +*.sw[po] +*.sqlite +*.sqlite3 + +docs/_build +build/ +dist/ +*.egg-info/ +.tox/ +.idea/ +*.python-version +.coverage + +djutils/ +manage.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..86a0bd5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +Copyright Willem Van Onsem (c) 2025 + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Willem Van Onsem nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..3225382 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include README.md +recursive-include django_user_pinned/locale/ **.po **.mo diff --git a/README.md b/README.md new file mode 100644 index 0000000..34a3213 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Django-user-pinned + +[![PyPi version](https://badgen.net/pypi/v/django-user-pinned/)](https://pypi.python.org/pypi/django-user-pinned/) +[![Documentation Status](https://readthedocs.org/projects/django-user-pinned/badge/?version=latest)](http://django-user-pinned.readthedocs.io/?badge=latest) +[![PyPi license](https://badgen.net/pypi/license/django-user-pinned/)](https://pypi.python.org/pypi/django-user-pinned/) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + diff --git a/django_user_pinned/__init__.py b/django_user_pinned/__init__.py new file mode 100644 index 0000000..1327fa4 --- /dev/null +++ b/django_user_pinned/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.1" +__author__ = "Willem Van Onsem" diff --git a/django_user_pinned/admin.py b/django_user_pinned/admin.py new file mode 100644 index 0000000..0417c06 --- /dev/null +++ b/django_user_pinned/admin.py @@ -0,0 +1,35 @@ +from django.contrib import admin +from django.contrib.auth import get_permission_codename +from django.utils.translation import gettext as _ + + +class PinnableAdminMixin: + def get_queryset(self, request): + # include number of pins and if the current user is a pin + return super().get_queryset(request).with_pinned(request.user).with_pins() + + def get_list_display(self, request): + return [*super().get_list_display(request), 'pin_star'] + + def _get_base_actions(self): + return [*super()._get_base_actions(), *filter(None, map(self.get_action, ('pin', 'unpin')))] + + @admin.action(description=_("Pin items"), permissions=['pin']) + def pin(self, request, queryset): + model_name = self.model._meta.model_name + getattr(request.user, f'pinned_{model_name}s').add(*queryset) + + def has_pin_permission(self, request): + opts = self.opts + codename = get_permission_codename('pin', opts) + return request.user.has_perm(f'{opts.app_label}.{codename}') + + @admin.action(description=_("Unpin items"), permissions=['unpin']) + def unpin(self, request, queryset): + model_name = self.model._meta.model_name + getattr(request.user, f'pinned_{model_name}s').remove(*queryset) + + def has_unpin_permission(self, request): + opts = self.opts + codename = get_permission_codename('unpin', opts) + return request.user.has_perm(f'{opts.app_label}.{codename}') \ No newline at end of file diff --git a/django_user_pinned/apps.py b/django_user_pinned/apps.py new file mode 100644 index 0000000..9c89fb9 --- /dev/null +++ b/django_user_pinned/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class SingleSessionConfig(AppConfig): + """ + The app config for the single_session app. + """ + + name = "django_user_pinned" + verbose_name = "django user pinned" + default_auto_field = "django.db.models.BigAutoField" \ No newline at end of file diff --git a/django_user_pinned/locale/nl/LC_MESSAGES/django.po b/django_user_pinned/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 0000000..4247030 --- /dev/null +++ b/django_user_pinned/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,35 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025 Willem Van Onsem +# This file is distributed under the same license as the PACKAGE package. +# Willem Van Onsem , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-16 15:05+0100\n" +"PO-Revision-Date: 2025-02-16 15:05+0100\n" +"Last-Translator: Willem Van Onsem \n" +"Language-Team: NL \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: django_user_pinned/admin.py:21 +msgid "Pin items" +msgstr "Markeer als favoriet" + +#: django_user_pinned/admin.py:31 +msgid "Unpin items" +msgstr "Verwijder als favoriet" + +#: django_user_pinned/models.py:68 +msgid "a user can add an object to their selection" +msgstr "Een gebruiker kan objecten aan zijn/haar selectie toevoegen" + +#: django_user_pinned/models.py:69 +msgid "a user can remove an object from their selection" +msgstr "Een gebruiker kan objecten van zijn/haar selectie verwijderen" diff --git a/django_user_pinned/migrations/__init__.py b/django_user_pinned/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_user_pinned/models.py b/django_user_pinned/models.py new file mode 100644 index 0000000..9a3df71 --- /dev/null +++ b/django_user_pinned/models.py @@ -0,0 +1,70 @@ +from django.db import models +from django.conf import settings +from django.db.models.aggregates import Count +from django.db.models.expressions import Exists, OuterRef +from django.db.models.query_utils import Q +from django.contrib import admin +from django.utils.translation import gettext as _ + + +STAR_CHARS = '☆★' + + +class PinnableQuerySet(models.QuerySet): + def with_pinned(self, user, *users): + model = self.model + rel = model.pinned_by + junction = rel.through + rel_name = rel.rel.model._meta.model_name + if users: + rel_name = f'{rel_name}__in' + user = (user, *users) + return self.annotate( + is_pinned=Exists(junction.objects.filter( + Q((self.model._meta.model_name, OuterRef('pk')), + Q((rel_name, user)) + ))) + ) + + def with_pinned_sorted(self, user, *users): + return self.get_pinned(user, *users).order_by('-is_pinned') + + def with_pins(self): + return self.annotate(_pins=Count('pinned_by', distinct=True)) + + def with_pins_sorted(self): + return self.with_pins().order_by('-_pins') + + +class PinnableModel(models.Model): + pinned_by = models.ManyToManyField(settings.AUTH_USER_MODEL, symmetrical=False, related_name='pinned_%(class)ss', blank=True, editable=False) + objects = PinnableQuerySet.as_manager() + + @property + def pins(self): + pins = getattr(self, '_pins', None) + if pins is None: + self._pins = pins = self.pinned_by.count() + return pins + + @property + def pin_star_char(self): + return STAR_CHARS[bool(getattr(self, 'is_pinned', False))] + + @property + @admin.display(description='pins', ordering='_pins') + def pin_star(self): + return f'{self.pins} {self.pin_star_char}' + + def pin(self, user, *users): + self.pinned_by.add(user, *users) + + def unpin(self, user, *users): + self.pinned_by.remove(user, *users) + + class Meta: + abstract = True + permissions = [ + ('pin', _('a user can add an object to their selection')), + ('unpin', _('a user can remove an object from their selection')), + ] \ No newline at end of file diff --git a/django_user_pinned/signals.py b/django_user_pinned/signals.py new file mode 100644 index 0000000..e69de29 diff --git a/django_user_pinned/tests.py b/django_user_pinned/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/django_user_pinned/views.py b/django_user_pinned/views.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7474bdc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "django-user-pinned" +version = "0.1.0" +authors = [{name = "Willem Van Onsem", email = "yourfriends@hapytex.eu"}] + +[build-system] +requires = ['setuptools>=45', 'wheel', 'setuptools_scm[toml]>=6.2'] +build-backend = 'setuptools.build_meta:__legacy__' + +[tool.setuptools_scm] +write_to = "_version.py" + +[tool.black] +extend-exclude = '(.*/migrations/.*|setup)\.py' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..04cb098 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +django >= 3.0.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..69f0816 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,34 @@ +[metadata] +name = django-user-pinned +version= file: _version.py +description = Allow users to "subscribe" or "pin" certain model instances. +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/hapytex/django-user-pinned/ +author = Willem Van Onsem +author_email = yourfriends@hapytex.eu +license = BSD-3-Clause +classifiers = + Environment :: Web Environment + Framework :: Django + Framework :: Django :: 3.0 + Framework :: Django :: 3.1 + Framework :: Django :: 3.2 + Framework :: Django :: 4.0 + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Topic :: Internet :: WWW/HTTP + Topic :: Internet :: WWW/HTTP :: Dynamic Content + +[options] +include_package_data = true +packages = find: +python_requires = >=3.8 +install_requires = + Django >= 3.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6068493 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup()