├── .coveragerc ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── AUTHORS ├── CHANGELOG ├── INSTALL ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── build │ └── empty └── source │ ├── _static │ └── empty │ ├── _templates │ └── empty │ ├── conf.py │ ├── index.rst │ ├── models.rst │ ├── quickstart.rst │ └── rst_guide.rst ├── pytest.ini ├── setup.cfg ├── setup.py ├── siteflags ├── __init__.py ├── admin.py ├── apps.py ├── locale │ ├── empty │ ├── en │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── ru │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_basic.py │ └── testapp │ │ ├── __init__.py │ │ └── models.py └── utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = siteflags/* 3 | omit = siteflags/migrations/*, siteflags/tests/*, siteflags/admin.py, siteflags/config.py 4 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10"] 18 | django-version: [2.0, 2.1, 2.2, 3.0, 3.1, 3.2, 4.0] 19 | 20 | exclude: 21 | 22 | - python-version: 3.7 23 | django-version: 4.0 24 | 25 | - python-version: 3.6 26 | django-version: 4.0 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | - name: Set up Python ${{ matrix.python-version }} & Django ${{ matrix.django-version }} 31 | uses: actions/setup-python@v2 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Install deps 35 | run: | 36 | python -m pip install pytest coverage coveralls "Django~=${{ matrix.django-version }}.0" 37 | - name: Run tests 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.github_token }} 40 | run: | 41 | coverage run --source=siteflags setup.py test 42 | coveralls --service=github 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | .idea 4 | .tox 5 | __pycache__ 6 | *.pyc 7 | *.pyo 8 | *.egg-info 9 | docs/_build/ 10 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | django-siteflags authors 2 | ======================== 3 | 4 | Created by Igor `idle sign` Starikov. 5 | 6 | 7 | Contributors 8 | ------------ 9 | 10 | BoPeng 11 | 12 | 13 | 14 | Translators 15 | ----------- 16 | 17 | Russian: Igor Starikov 18 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | django-siteflags changelog 2 | ========================== 3 | 4 | 5 | v1.3.0 [2022-01-28] 6 | ------------------- 7 | ! Basic methods now use keyword-only arguments to improve code readability. 8 | + Introduced 'with_objects' argument for 'ModelWithFlag.get_flags_for_type' (see #11). 9 | * Introduced 'ModelWithFlag.get_flags_for_type' to improve semantics. 10 | 11 | 12 | v1.2.0 [2020-05-28] 13 | ------------------- 14 | ! Dropped support for Python 2 and 3.5. 15 | * Dropped QA for Django2.0. 16 | 17 | 18 | v1.1.0 [2019-12-07] 19 | ------------------- 20 | ! Dropped QA for Django 1.7. 21 | ! Dropped QA for Python 2. 22 | + Add Django 3.0 compatibility. 23 | 24 | 25 | v1.0.0 26 | ------ 27 | ! Dropped QA for Python 3.4. 28 | * No functional changes. Celebrating 1.0.0. 29 | 30 | 31 | v0.5.0 32 | ------ 33 | + Django 2.0 basic compatibility. 34 | * Dropped support for Python<3.4 and Django<1.7. 35 | 36 | 37 | v0.4.2 38 | ------ 39 | * Package distribution fix. 40 | 41 | 42 | v0.4.1 43 | ------ 44 | * Django 1.9 compatibility improvements. 45 | 46 | 47 | v0.4.0 48 | ------ 49 | * IMPORTANT: Added migrations. Use `migrate siteflags --fake`. 50 | + Added `allow_empty` parameter support for get_flags_for_types() methods. 51 | 52 | 53 | v0.3.0 54 | ------ 55 | + Implemented ModelWithFlag.get_flags_for_types(). 56 | + Django 1.7 ready. 57 | 58 | 59 | v0.2.0 60 | ------ 61 | + Implemented ModelWithFlag.get_flags_for_objects(). 62 | 63 | 64 | v0.1.0 65 | ------ 66 | + Basic functionality. -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | django-siteflags installation 2 | ============================= 3 | 4 | 5 | Python ``pip`` package is required to install ``django-siteflags``. 6 | 7 | 8 | From sources 9 | ------------ 10 | 11 | Use the following command line to install ``django-siteflags`` from sources directory (containing setup.py): 12 | 13 | pip install . 14 | 15 | or 16 | 17 | python setup.py install 18 | 19 | 20 | From PyPI 21 | --------- 22 | 23 | Alternatively you can install ``django-siteflags`` from PyPI: 24 | 25 | pip install django-siteflags 26 | 27 | 28 | Use `-U` flag for upgrade: 29 | 30 | pip install -U django-siteflags 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2022, Igor `idle sign` Starikov 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the django-siteflags nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include CHANGELOG 3 | include INSTALL 4 | include LICENSE 5 | include README.rst 6 | 7 | include docs/Makefile 8 | recursive-include docs *.rst 9 | recursive-include docs *.py 10 | 11 | recursive-include siteflags/tests * 12 | recursive-include siteflags/locale * 13 | recursive-include siteflags/migrations *.py 14 | recursive-include siteflags/south_migrations *.py 15 | 16 | recursive-exclude * __pycache__ 17 | recursive-exclude * *.py[co] 18 | recursive-exclude * empty 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-siteflags 2 | ================ 3 | https://github.com/idlesign/django-siteflags 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-siteflags.svg 6 | :target: https://pypi.python.org/pypi/django-siteflags 7 | 8 | .. image:: https://img.shields.io/pypi/l/django-siteflags.svg 9 | :target: https://pypi.python.org/pypi/django-siteflags 10 | 11 | .. image:: https://img.shields.io/coveralls/idlesign/django-siteflags/master.svg 12 | :target: https://coveralls.io/r/idlesign/django-siteflags 13 | 14 | 15 | Description 16 | ----------- 17 | 18 | *Reusable application for Django allowing users to flag/bookmark site objects* 19 | 20 | So you want a user to be able to put some flags on certain site entities. 21 | 22 | Let's say you need a kind of bookmark powered service, or a site where content is flagged and moderated, 23 | or a simplified rating system, or something similar. 24 | 25 | Inherit you model from ``siteflags.models.ModelWithFlag`` and you're almost done. 26 | 27 | Like that: 28 | 29 | .. code-block:: python 30 | 31 | # myapp/models.py 32 | from siteflags.models import ModelWithFlag 33 | 34 | 35 | class Article(ModelWithFlag): 36 | 37 | ... # Some model fields here. 38 | 39 | 40 | And like so: 41 | 42 | .. code-block:: python 43 | 44 | # myapp/views.py 45 | from django.shortcuts import get_object_or_404 46 | from .models import Article 47 | 48 | 49 | def article_details(request, article_id): 50 | 51 | article = get_object_or_404(Article, pk=article_id) 52 | 53 | user = request.user 54 | article.set_flag(user) 55 | article.is_flagged(user) 56 | article.remove_flag(user) 57 | 58 | ... 59 | 60 | 61 | Quite simple. Quite generic. Read the documentation. 62 | 63 | 64 | Documentation 65 | ------------- 66 | 67 | http://django-siteflags.readthedocs.org/ 68 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-siteflags.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-siteflags.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-siteflags" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-siteflags" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/build/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteflags/77503b141ed38f62066f48d99dd9fd9d160c7e1b/docs/build/empty -------------------------------------------------------------------------------- /docs/source/_static/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteflags/77503b141ed38f62066f48d99dd9fd9d160c7e1b/docs/source/_static/empty -------------------------------------------------------------------------------- /docs/source/_templates/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteflags/77503b141ed38f62066f48d99dd9fd9d160c7e1b/docs/source/_templates/empty -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-siteflags documentation build configuration file. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import sys, os 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | sys.path.insert(0, os.path.abspath('../../')) 19 | from siteflags import VERSION 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'django-siteflags' 44 | copyright = u'2014-2022, Igor `idle sign` Starikov' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '.'.join(map(str, VERSION)) 52 | # The full version, including alpha/beta/rc tags. 53 | release = '.'.join(map(str, VERSION)) 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = [] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | #html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | #html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | #html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | #html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = 'django-siteflagsdoc' 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | # The paper size ('letter' or 'a4'). 173 | #latex_paper_size = 'letter' 174 | 175 | # The font size ('10pt', '11pt' or '12pt'). 176 | #latex_font_size = '10pt' 177 | 178 | # Grouping the document tree into LaTeX files. List of tuples 179 | # (source start file, target name, title, author, documentclass [howto/manual]). 180 | latex_documents = [ 181 | ('index', 'django-siteflags.tex', u'django-siteflags Documentation', 182 | u'Igor `idle sign` Starikov', 'manual'), 183 | ] 184 | 185 | # The name of an image file (relative to this directory) to place at the top of 186 | # the title page. 187 | #latex_logo = None 188 | 189 | # For "manual" documents, if this is true, then toplevel headings are parts, 190 | # not chapters. 191 | #latex_use_parts = False 192 | 193 | # If true, show page references after internal links. 194 | #latex_show_pagerefs = False 195 | 196 | # If true, show URL addresses after external links. 197 | #latex_show_urls = False 198 | 199 | # Additional stuff for the LaTeX preamble. 200 | #latex_preamble = '' 201 | 202 | # Documents to append as an appendix to all manuals. 203 | #latex_appendices = [] 204 | 205 | # If false, no module index is generated. 206 | #latex_domain_indices = True 207 | 208 | 209 | # -- Options for manual page output -------------------------------------------- 210 | 211 | # One entry per manual page. List of tuples 212 | # (source start file, name, description, authors, manual section). 213 | man_pages = [ 214 | ('index', 'django-siteflags', u'django-siteflags Documentation', 215 | [u'Igor `idle sign` Starikov'], 1) 216 | ] 217 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | django-siteflags documentation 2 | ============================== 3 | https://github.com/idlesign/django-siteflags 4 | 5 | 6 | 7 | Description 8 | ----------- 9 | 10 | *Reusable application for Django allowing users to flag/bookmark site objects* 11 | 12 | 13 | So you want a user to be able to put some flags on certain site entities. 14 | 15 | Let's say you need a kind of bookmark powered service, or a site where content is flagged and moderated, or a simplified rating system or something similar. 16 | 17 | 18 | 19 | Requirements 20 | ------------ 21 | 22 | 1. Python 3.6+ 23 | 2. Django 2.0+ 24 | 3. Django Auth contrib enabled 25 | 4. Django Admin contrib enabled (optional) 26 | 27 | 28 | Table of Contents 29 | ----------------- 30 | 31 | .. toctree:: 32 | :maxdepth: 2 33 | 34 | quickstart 35 | models 36 | 37 | 38 | Get involved into django-siteflags 39 | ---------------------------------- 40 | 41 | **Submit issues.** If you spotted something weird in application behavior or want to propose a feature you can do that at https://github.com/idlesign/django-siteflags/issues 42 | 43 | **Write code.** If you are eager to participate in application development, fork it at https://github.com/idlesign/django-siteflags, write your code, whether it should be a bugfix or a feature implementation, and make a pull request right from the forked project page. 44 | 45 | **Translate.** If want to translate the application into your native language use Transifex: https://www.transifex.com/projects/p/django-siteflags/. 46 | 47 | **Spread the word.** If you have some tips and tricks or any other words in mind that you think might be of interest for the others — publish them. 48 | 49 | 50 | Also 51 | ---- 52 | 53 | If the application is not what you want for content flagging/bookmarking, you might be interested in considering other choices — https://www.djangopackages.com/grids/g/bookmarking/ 54 | -------------------------------------------------------------------------------- /docs/source/models.rst: -------------------------------------------------------------------------------- 1 | ModelWithFlag Model 2 | =================== 3 | 4 | ``siteflags.models.ModelWithFlag`` is practically all that's needed for flagging. 5 | 6 | 7 | 8 | Methods 9 | ------- 10 | 11 | .. py:method:: get_flags_for_type([mdl_classes=None, [user=None[, status=None[, allow_empty=False]]]]): 12 | 13 | Returns a dictionary with flag objects associated with the given model classes (types). 14 | The dictionary is indexed by model classes. 15 | Each dict entry contains a list of associated flag objects. 16 | 17 | :param list mdl_classes: Classes objects (types) list to get flags for. 18 | :param User user: Optional user filter 19 | :param int status: Optional status filter 20 | :param bool allow_empty: Include results for all given types, even those without associated flags. 21 | 22 | 23 | .. py:method:: get_flags_for_objects(objects_list, [user=None[, status=None]]): 24 | 25 | Returns a dictionary with flag objects associated with the given objects. 26 | The dictionary is indexed by objects IDs. 27 | Each dict entry contains a list of associated flag objects. 28 | 29 | :param list, QuerySet objects_list: Homogeneous objects list to get flags for. 30 | :param User user: Optional user filter 31 | :param int status: Optional status filter 32 | 33 | 34 | .. py:method:: get_flags([user=None[, status=None]]): 35 | 36 | Returns flags for the object optionally filtered by user and/or status. 37 | 38 | :param User user: Optional user filter 39 | :param int status: Optional status filter 40 | 41 | 42 | .. py:method:: set_flag(user[, note=None[, status=None]]): 43 | 44 | Flags the object. 45 | 46 | :param User user: 47 | :param str note: User-defined note for this flag. 48 | :param int status: Optional status integer (the meaning is defined by a developer). 49 | 50 | 51 | .. py:method:: remove_flag([user=None[, status=None]]): 52 | 53 | Removes flag(s) from the object. 54 | 55 | :param User user: Optional user filter 56 | :param int status: Optional status filter 57 | 58 | 59 | .. py:method:: is_flagged([user=None[, status=None]]): 60 | 61 | Returns boolean whether the objects is flagged by a user. 62 | 63 | :param User user: 64 | :param int status: Optional status filter 65 | 66 | 67 | 68 | Customization 69 | ------------- 70 | 71 | SiteFlags allows you to customize Flags model. 72 | 73 | 1. Define your own ``flag`` model inherited from ``FlagBase``. 74 | 75 | 2. Now when ``models.py`` in your application has the definition of a custom flags model, you need 76 | to instruct Django to use it for your project instead of a built-in one: 77 | 78 | .. code-block:: python 79 | 80 | # Somewhere in your settings.py do the following. 81 | # Here `myapp` is the name of your application, `MyFlag` is the names of your customized model. 82 | SITEFLAGS_FLAG_MODEL = 'myapp.MyFlag' 83 | 84 | 85 | 3. Run ``manage.py makemigrations`` and ``manage.py migrate`` to install your customized models into DB. 86 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | .. note:: 5 | 6 | Do not forget to add the ``siteflags`` application to ``INSTALLED_APPS`` in your settings file (usually ``settings.py``) 7 | and apply migrations. 8 | 9 | 10 | Let's suppose we want our users to report fake articles. 11 | 12 | Inherit your model from ``siteflags.models.ModelWithFlag`` and you're almost done. 13 | 14 | myapp/models.py 15 | ~~~~~~~~~~~~~~~ 16 | 17 | 18 | .. code-block:: python 19 | 20 | from siteflags.models import ModelWithFlag 21 | 22 | class Article(ModelWithFlag): 23 | 24 | FLAG_FAKE = 10 25 | """Let's suppose we have several flag types. 26 | And this is a flag status for "fake" flag type. 27 | 28 | """ 29 | 30 | FLAG_BOOKMARK = 20 31 | """And this is a flag status for "bookmark" flag type.""" 32 | 33 | ... # Some model fields here. 34 | 35 | # Now we may want define fake-related helper methods. 36 | 37 | def fake_mark_add(self, user, note): 38 | return self.set_flag(user, note=note, status=self.FLAG_FAKE) 39 | 40 | def fake_mark_remove(self, user): 41 | return self.remove_flag(user, status=self.FLAG_FAKE) 42 | 43 | def fake_mark_check(self, user): 44 | return self.is_flagged(user, status=self.FLAG_FAKE) 45 | 46 | ... # Maybe also some helper methods for FLAG_BOOKMARK. 47 | 48 | 49 | 50 | myapp/views.py 51 | ~~~~~~~~~~~~~~ 52 | 53 | .. code-block:: python 54 | 55 | 56 | from django.shortcuts import get_object_or_404 57 | from .models import Article 58 | 59 | 60 | def article_details(request, article_id): 61 | 62 | article = get_object_or_404(Article, pk=article_id) 63 | 64 | user = request.user 65 | # Let's suppose we have here only logged in users. 66 | 67 | post = request.POST 68 | 69 | if post.get('fake_set'): 70 | # Now a user reports this article as a fake. 71 | article.fake_mark_add(user, note=post.get('fake_message')) 72 | 73 | elif post.get('fake_remove'): 74 | # Or he removes a fake flag. 75 | article.fake_mark_remove(user) 76 | 77 | is_fake = article.fake_mark_check(user) 78 | # This you may want to pass into a template to show flag state. 79 | 80 | ... # Maybe also some handling for FLAG_BOOKMARK. 81 | 82 | # That's how we get all article flags (any type/status) 83 | # for the current user. 84 | all_flags = article.get_flags(user) 85 | 86 | ... # Maybe render a template here. 87 | 88 | 89 | There are even more generic API methods: 90 | 91 | .. code-block:: python 92 | 93 | from siteflags.models import ModelWithFlag 94 | 95 | # We can find flags of any type for various objects. 96 | # Let's pretend we also 'article', 'video' and 'image' objects 97 | # available in the current scope. 98 | flags = ModelWithFlag.get_flags_for_objects([article, video, image]) 99 | 100 | # We can also find flags of any type by type. 101 | # Let's also prefetch Article objects (with_objects=True). 102 | flags = Article.get_flags_for_type(with_objects=True) 103 | # And that's practically would be the same as in 'all_flags' 104 | # of the above mentioned view. 105 | 106 | for flag in flags: 107 | # Since we've prefetched the linked objects with our flags 108 | # we can access article properties without additional DB hits. 109 | print(f'article: {flag.linked_object.id}') 110 | 111 | 112 | .. note:: You can also customize ``Flag`` model by inheriting from ``siteflags.models.FlagBase`` 113 | and setting ``SITEFLAGS_FLAG_MODEL`` in your ``settings.py``, for example: 114 | 115 | .. code-block:: python 116 | 117 | SITEFLAGS_FLAG_MODEL = 'myapp.MyFlag' 118 | 119 | And that's how it's done. 120 | 121 | .. warning:: If you use a custom model and override ``Meta``, be sure to inherit it from ``FlagBase.Meta``. 122 | Otherwise you may miss ``unique_together`` constraints from the base class. 123 | 124 | -------------------------------------------------------------------------------- /docs/source/rst_guide.rst: -------------------------------------------------------------------------------- 1 | RST Quick guide 2 | =============== 3 | 4 | Online reStructuredText editor - http://rst.ninjs.org/ 5 | 6 | 7 | Main heading 8 | ============ 9 | 10 | 11 | Secondary heading 12 | ----------------- 13 | 14 | 15 | 16 | Typography 17 | ---------- 18 | 19 | **Bold** 20 | 21 | `Italic` 22 | 23 | ``Accent`` 24 | 25 | 26 | 27 | Blocks 28 | ------ 29 | 30 | Double colon to consider the following paragraphs preformatted:: 31 | 32 | This text is preformated. Can be used for code samples. 33 | 34 | 35 | .. code-block:: python 36 | 37 | # code-block accepts language name to highlight code 38 | # E.g.: python, html 39 | import this 40 | 41 | 42 | .. note:: 43 | 44 | This text will be rendered as a note block (usually green). 45 | 46 | 47 | .. warning:: 48 | 49 | This text will be rendered as a warning block (usually red). 50 | 51 | 52 | 53 | Lists 54 | ----- 55 | 56 | 1. Ordered item 1. 57 | 58 | Indent paragraph to make in belong to the above list item. 59 | 60 | 2. Ordered item 2. 61 | 62 | 63 | + Unordered item 1. 64 | + Unordered item . 65 | 66 | 67 | 68 | Links 69 | ----- 70 | 71 | :ref:`Documentation inner link label ` 72 | 73 | .. _some-marker: 74 | 75 | 76 | `Outer link label `_ 77 | 78 | Inline URLs are converted to links automatically: http://github.com/idlesign/makeapp/ 79 | 80 | 81 | 82 | Automation 83 | ---------- 84 | 85 | http://sphinx-doc.org/ext/autodoc.html 86 | 87 | .. automodule:: my_module 88 | :members: 89 | 90 | .. autoclass:: my_module.MyClass 91 | :members: do_this, do_that 92 | :inherited-members: 93 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --pyargs siteflags 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | release = clean --all sdist bdist_wheel upload 3 | test = pytest 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import re 4 | import sys 5 | 6 | from setuptools import setup, find_packages 7 | 8 | PATH_BASE = os.path.dirname(__file__) 9 | 10 | 11 | def read_file(fpath): 12 | """Reads a file within package directories.""" 13 | with io.open(os.path.join(PATH_BASE, fpath)) as f: 14 | return f.read() 15 | 16 | 17 | def get_version(): 18 | """Returns version number, without module import (which can lead to ImportError 19 | if some dependencies are unavailable before install.""" 20 | contents = read_file(os.path.join('siteflags', '__init__.py')) 21 | version = re.search('VERSION = \(([^)]+)\)', contents) 22 | version = version.group(1).replace(', ', '.').strip() 23 | return version 24 | 25 | 26 | setup( 27 | name='django-siteflags', 28 | version=get_version(), 29 | url='https://github.com/idlesign/django-siteflags', 30 | 31 | description='Reusable application for Django allowing users to flag/bookmark site objects', 32 | long_description=read_file('README.rst'), 33 | license='BSD 3-Clause License', 34 | 35 | author='Igor `idle sign` Starikov', 36 | author_email='idlesign@yandex.ru', 37 | 38 | packages=find_packages(), 39 | include_package_data=True, 40 | zip_safe=False, 41 | 42 | install_requires=[ 43 | 'django-etc>=1.2.0', 44 | ], 45 | setup_requires=[] + (['pytest-runner'] if 'test' in sys.argv else []), 46 | 47 | test_suite='tests', 48 | tests_require=[ 49 | 'pytest', 50 | 'pytest-djangoapp>=0.15.1', 51 | ], 52 | 53 | classifiers=[ 54 | # As in https://pypi.python.org/pypi?:action=list_classifiers 55 | 'Development Status :: 5 - Production/Stable', 56 | 'Operating System :: OS Independent', 57 | 'Programming Language :: Python', 58 | 'Programming Language :: Python :: 3', 59 | 'Programming Language :: Python :: 3.6', 60 | 'Programming Language :: Python :: 3.7', 61 | 'Programming Language :: Python :: 3.8', 62 | 'Programming Language :: Python :: 3.9', 63 | 'Programming Language :: Python :: 3.10', 64 | 'License :: OSI Approved :: BSD License' 65 | ], 66 | ) 67 | -------------------------------------------------------------------------------- /siteflags/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 3, 0) 2 | 3 | default_app_config = 'siteflags.apps.SiteflagsConfig' -------------------------------------------------------------------------------- /siteflags/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .utils import get_flag_model 4 | 5 | FLAG_MODEL = get_flag_model() 6 | 7 | 8 | @admin.register(FLAG_MODEL) 9 | class FlagModelAdmin(admin.ModelAdmin): 10 | 11 | list_display = ( 12 | 'time_created', 13 | 'content_type', 14 | 'object_id', 15 | 'status', 16 | ) 17 | 18 | search_fields = ( 19 | 'object_id', 20 | 'content_type', 21 | 'user', 22 | ) 23 | 24 | list_filter = ( 25 | 'time_created', 26 | 'status', 27 | 'content_type', 28 | ) 29 | 30 | ordering = ( 31 | '-time_created', 32 | ) 33 | 34 | date_hierarchy = 'time_created' 35 | -------------------------------------------------------------------------------- /siteflags/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class SiteflagsConfig(AppConfig): 6 | """Siteflags configuration.""" 7 | 8 | name = 'siteflags' 9 | verbose_name = _('Site Flags') 10 | -------------------------------------------------------------------------------- /siteflags/locale/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteflags/77503b141ed38f62066f48d99dd9fd9d160c7e1b/siteflags/locale/empty -------------------------------------------------------------------------------- /siteflags/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2014-09-08 14:33+0700\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: config.py:9 21 | msgid "Site Flags" 22 | msgstr "" 23 | 24 | #: models.py:28 25 | msgid "Note" 26 | msgstr "" 27 | 28 | #: models.py:29 29 | msgid "Status" 30 | msgstr "" 31 | 32 | #: models.py:31 33 | msgid "User" 34 | msgstr "" 35 | 36 | #: models.py:32 37 | msgid "Date created" 38 | msgstr "" 39 | 40 | #: models.py:35 41 | msgid "Object ID" 42 | msgstr "" 43 | 44 | #: models.py:36 45 | msgid "Content type" 46 | msgstr "" 47 | 48 | #: models.py:42 49 | msgid "Flag" 50 | msgstr "" 51 | 52 | #: models.py:43 53 | msgid "Flags" 54 | msgstr "" 55 | -------------------------------------------------------------------------------- /siteflags/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteflags/77503b141ed38f62066f48d99dd9fd9d160c7e1b/siteflags/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /siteflags/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Igor Starikov , 2014 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-siteflags\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2014-09-08 14:33+0700\n" 12 | "PO-Revision-Date: 2014-09-08 14:35+0700\n" 13 | "Last-Translator: Igor 'idle sign' Starikov \n" 14 | "Language-Team: Russian (http://www.transifex.com/projects/p/django-siteflags/" 15 | "language/ru/)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Language: ru\n" 20 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 21 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 22 | "X-Generator: Poedit 1.5.4\n" 23 | 24 | #: config.py:9 25 | msgid "Site Flags" 26 | msgstr "Флаги сайта" 27 | 28 | #: models.py:28 29 | msgid "Note" 30 | msgstr "Заметка" 31 | 32 | #: models.py:29 33 | msgid "Status" 34 | msgstr "Статус" 35 | 36 | #: models.py:31 37 | msgid "User" 38 | msgstr "Пользователь" 39 | 40 | #: models.py:32 41 | msgid "Date created" 42 | msgstr "Дата создания" 43 | 44 | #: models.py:35 45 | msgid "Object ID" 46 | msgstr "ID объекта" 47 | 48 | #: models.py:36 49 | msgid "Content type" 50 | msgstr "Тип содержимого" 51 | 52 | #: models.py:42 53 | msgid "Flag" 54 | msgstr "Флаг" 55 | 56 | #: models.py:43 57 | msgid "Flags" 58 | msgstr "Флаги" 59 | -------------------------------------------------------------------------------- /siteflags/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('contenttypes', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Flag', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('note', models.TextField(verbose_name='Note', blank=True)), 21 | ('status', models.IntegerField(db_index=True, null=True, verbose_name='Status', blank=True)), 22 | ('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), 23 | ('object_id', models.PositiveIntegerField(verbose_name='Object ID', db_index=True)), 24 | ('content_type', models.ForeignKey(related_name='siteflags_flag_flags', verbose_name='Content type', to='contenttypes.ContentType', on_delete=models.CASCADE)), 25 | ('user', models.ForeignKey(related_name='flag_users', verbose_name='User', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 26 | ], 27 | options={ 28 | 'abstract': False, 29 | 'verbose_name': 'Flag', 30 | 'verbose_name_plural': 'Flags', 31 | }, 32 | bases=(models.Model,), 33 | ), 34 | migrations.AlterUniqueTogether( 35 | name='flag', 36 | unique_together=set([('content_type', 'object_id', 'user', 'status')]), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /siteflags/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteflags/77503b141ed38f62066f48d99dd9fd9d160c7e1b/siteflags/migrations/__init__.py -------------------------------------------------------------------------------- /siteflags/models.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import List, Type, Dict, Union, Tuple, Optional, Sequence 3 | 4 | from django.conf import settings 5 | from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.db import models, IntegrityError 8 | from django.db.models.query import QuerySet 9 | from django.utils.translation import gettext_lazy as _ 10 | from etc.toolbox import get_model_class_from_string 11 | 12 | from .settings import MODEL_FLAG 13 | from .utils import get_flag_model 14 | 15 | if False: # pragma: nocover 16 | from django.contrib.auth.models import User # noqa 17 | 18 | USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') 19 | 20 | TypeFlagsForType = List['FlagBase'] 21 | TypeFlagsForTypes = Dict[Type[models.Model], TypeFlagsForType] 22 | 23 | 24 | class FlagBase(models.Model): 25 | """Base class for flag models. 26 | Flags are marks on various site entities (model instances). 27 | 28 | Inherit from this model and override SITEFLAGS_FLAG_MODEL in settings.py 29 | to customize model fields and behaviour. 30 | 31 | """ 32 | note = models.TextField(_('Note'), blank=True) 33 | status = models.IntegerField(_('Status'), null=True, blank=True, db_index=True) 34 | 35 | user = models.ForeignKey( 36 | USER_MODEL, related_name='%(class)s_users', verbose_name=_('User'), 37 | on_delete=models.CASCADE) 38 | 39 | time_created = models.DateTimeField(_('Date created'), auto_now_add=True) 40 | 41 | # Here follows a link to an object. 42 | object_id = models.PositiveIntegerField(verbose_name=_('Object ID'), db_index=True) 43 | 44 | content_type = models.ForeignKey( 45 | ContentType, verbose_name=_('Content type'), 46 | related_name='%(app_label)s_%(class)s_flags', 47 | on_delete=models.CASCADE) 48 | 49 | linked_object = GenericForeignKey() 50 | 51 | class Meta: 52 | 53 | abstract = True 54 | 55 | verbose_name = _('Flag') 56 | verbose_name_plural = _('Flags') 57 | 58 | unique_together = ( 59 | 'content_type', 60 | 'object_id', 61 | 'user', 62 | 'status', 63 | ) 64 | 65 | @classmethod 66 | def get_flags_for_types( 67 | cls, 68 | mdl_classes: List[Type[models.Model]], 69 | *, 70 | user: 'User' = None, 71 | status: int = None, 72 | allow_empty: bool = True, 73 | with_objects: bool = False, 74 | 75 | ) -> TypeFlagsForTypes: 76 | """Returns a dictionary with flag objects associated with the given model classes (types). 77 | The dictionary is indexed by model classes. 78 | Each dict entry contains a list of associated flags. 79 | 80 | :param mdl_classes: Types to get flags for. 81 | :param user: User filter, 82 | :param status: Status filter 83 | :param allow_empty: Flag. Include results for all given types, even those without associated flags. 84 | :param with_objects: Whether to fetch the flagged objects along with the flags. 85 | 86 | """ 87 | if not mdl_classes or (user and not user.id): 88 | return {} 89 | 90 | types_for_models = ContentType.objects.get_for_models(*mdl_classes, for_concrete_models=False) 91 | filter_kwargs = {'content_type__in': types_for_models.values()} 92 | update_filter_dict(filter_kwargs, user=user, status=status) 93 | 94 | flags = cls.objects.filter(**filter_kwargs) 95 | 96 | if with_objects: 97 | flags = flags.prefetch_related('linked_object') 98 | 99 | flags = flags.order_by('-time_created') 100 | 101 | flags_dict = defaultdict(list) 102 | 103 | for flag in flags: 104 | flags_dict[flag.content_type_id].append(flag) 105 | 106 | result = {} # Respect initial order. 107 | 108 | for mdl_cls in mdl_classes: 109 | 110 | content_type_id = types_for_models[mdl_cls].id 111 | 112 | if content_type_id in flags_dict: 113 | result[mdl_cls] = flags_dict[content_type_id] 114 | 115 | elif allow_empty: 116 | result[mdl_cls] = [] 117 | 118 | return result 119 | 120 | @classmethod 121 | def get_flags_for_objects( 122 | cls, 123 | objects_list: Union[QuerySet, Sequence], 124 | *, 125 | user: 'User' = None, 126 | status: int = None 127 | 128 | ) -> Dict[int, TypeFlagsForType]: 129 | """Returns a dictionary with flag objects associated with the given model objects. 130 | The dictionary is indexed by objects IDs. 131 | Each dict entry contains a list of associated flag objects. 132 | 133 | :param objects_list: 134 | :param user: 135 | :param status: 136 | 137 | """ 138 | if not objects_list or (user and not user.id): 139 | return {} 140 | 141 | objects_ids = objects_list 142 | if not isinstance(objects_list, QuerySet): 143 | objects_ids = [obj.pk for obj in objects_list] 144 | 145 | filter_kwargs = { 146 | 'object_id__in': objects_ids, 147 | # Consider this list homogeneous. 148 | 'content_type': ContentType.objects.get_for_model(objects_list[0], for_concrete_model=False) 149 | } 150 | update_filter_dict(filter_kwargs, user=user, status=status) 151 | 152 | flags = cls.objects.filter(**filter_kwargs) 153 | flags_dict = defaultdict(list) 154 | 155 | for flag in flags: 156 | flags_dict[flag.object_id].append(flag) 157 | 158 | result = {} 159 | 160 | for obj in objects_list: 161 | result[obj.pk] = flags_dict.get(obj.pk, []) 162 | 163 | return result 164 | 165 | def __str__(self): 166 | return f'{self.content_type}:{self.object_id} status {self.status}' 167 | 168 | 169 | class Flag(FlagBase): 170 | """Built-in flag class. Default functionality.""" 171 | 172 | 173 | class ModelWithFlag(models.Model): 174 | """Helper base class for models with flags. 175 | 176 | Inherit from this model to be able to mark model instances. 177 | 178 | """ 179 | flags = GenericRelation(MODEL_FLAG) 180 | 181 | class Meta: 182 | abstract = True 183 | 184 | @classmethod 185 | def get_flags_for_type( 186 | cls, 187 | mdl_classes: List[Type[models.Model]] = None, 188 | *, 189 | user: 'User' = None, 190 | status: int = None, 191 | allow_empty: bool = True, 192 | with_objects: bool = False, 193 | 194 | ) -> Union[TypeFlagsForTypes, TypeFlagsForType]: 195 | """Returns a dictionary with flag objects associated with 196 | the given model classes (types) if mdl_classes is given. 197 | The dictionary is indexed by model classes. 198 | Each dict entry contains a list of associated flag objects. 199 | 200 | If mdl_classes is not given, returns a list of associated 201 | flag objects for this very class. 202 | 203 | :param mdl_classes: Types to get flags for. If not set the current class is used. 204 | :param user: User filter, 205 | :param status: Status filter 206 | :param allow_empty: Flag. Include results for all given types, even those without associated flags. 207 | :param with_objects: Whether to fetch the flagged objects along with the flags. 208 | 209 | """ 210 | model: FlagBase = get_model_class_from_string(MODEL_FLAG) 211 | 212 | single_type = False 213 | if mdl_classes is None: 214 | mdl_classes = [cls] 215 | single_type = True 216 | allow_empty = True 217 | 218 | result = model.get_flags_for_types( 219 | mdl_classes, 220 | user=user, 221 | status=status, 222 | allow_empty=allow_empty, 223 | with_objects=with_objects, 224 | ) 225 | 226 | if single_type: 227 | result = result[cls] 228 | 229 | return result 230 | 231 | get_flags_for_types = get_flags_for_type # alias 232 | 233 | @classmethod 234 | def get_flags_for_objects( 235 | cls, 236 | objects_list: Union[QuerySet, Sequence], 237 | *, 238 | user: 'User' = None, 239 | status: int = None 240 | 241 | ) -> Dict[int, TypeFlagsForType]: 242 | """Returns a dictionary with flag objects associated with the given model objects. 243 | The dictionary is indexed by objects IDs. 244 | Each dict entry contains a list of associated flag objects. 245 | 246 | :param objects_list: 247 | :param user: 248 | :param status: 249 | 250 | """ 251 | model: FlagBase = get_model_class_from_string(MODEL_FLAG) 252 | return model.get_flags_for_objects(objects_list, user=user, status=status) 253 | 254 | def get_flags(self, user: 'User' = None, *, status: int = None) -> Union[QuerySet, Sequence[FlagBase]]: 255 | """Returns flags for the object optionally filtered by status. 256 | 257 | :param user: Optional user filter 258 | :param status: Optional status filter 259 | 260 | """ 261 | filter_kwargs = {} 262 | update_filter_dict(filter_kwargs, user=user, status=status) 263 | return self.flags.filter(**filter_kwargs).all() 264 | 265 | def set_flag(self, user: 'User', *, note: str = None, status: int = None) -> Optional[FlagBase]: 266 | """Flags the object. 267 | 268 | :param user: 269 | :param note: User-defined note for this flag. 270 | :param status: Optional status integer (the meaning is defined by a developer). 271 | 272 | """ 273 | if not user.id: 274 | return None 275 | 276 | init_kwargs = { 277 | 'user': user, 278 | 'linked_object': self, 279 | } 280 | if note is not None: 281 | init_kwargs['note'] = note 282 | 283 | if status is not None: 284 | init_kwargs['status'] = status 285 | 286 | flag = get_flag_model()(**init_kwargs) 287 | 288 | try: 289 | flag.save() 290 | 291 | except IntegrityError: # Record already exists. 292 | return None 293 | 294 | return flag 295 | 296 | def remove_flag(self, user: 'User' = None, *, status: int = None): 297 | """Removes flag(s) from the object. 298 | 299 | :param user: Optional user filter 300 | :param status: Optional status filter 301 | 302 | """ 303 | filter_kwargs = { 304 | 'content_type': ContentType.objects.get_for_model(self), 305 | 'object_id': self.id 306 | } 307 | update_filter_dict(filter_kwargs, user=user, status=status) 308 | get_flag_model().objects.filter(**filter_kwargs).delete() 309 | 310 | def is_flagged(self, user: 'User' = None, *, status: int = None) -> int: 311 | """Returns a number of times the object is flagged by a user. 312 | 313 | :param user: Optional user filter 314 | :param status: Optional status filter 315 | 316 | """ 317 | if user and user.is_anonymous: 318 | return False 319 | 320 | filter_kwargs = { 321 | 'content_type': ContentType.objects.get_for_model(self), 322 | 'object_id': self.id, 323 | } 324 | update_filter_dict(filter_kwargs, user=user, status=status) 325 | return self.flags.filter(**filter_kwargs).count() 326 | 327 | 328 | def update_filter_dict(d: dict, *, user: Optional['User'], status: Optional[int]): 329 | """Helper. Updates filter dict for a queryset. 330 | 331 | :param d: 332 | :param user: 333 | :param status: 334 | 335 | """ 336 | if user is not None: 337 | 338 | if not user.id: 339 | return None 340 | 341 | d['user'] = user 342 | 343 | if status is not None: 344 | d['status'] = status 345 | -------------------------------------------------------------------------------- /siteflags/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | MODEL_FLAG = getattr(settings, 'SITEFLAGS_FLAG_MODEL', 'siteflags.Flag') 5 | """Dotted path to a Flag custom model in form of `app.Model`.""" 6 | -------------------------------------------------------------------------------- /siteflags/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteflags/77503b141ed38f62066f48d99dd9fd9d160c7e1b/siteflags/tests/__init__.py -------------------------------------------------------------------------------- /siteflags/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pytest_djangoapp import configure_djangoapp_plugin 2 | 3 | 4 | pytest_plugins = configure_djangoapp_plugin() 5 | -------------------------------------------------------------------------------- /siteflags/tests/test_basic.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | from siteflags.models import ModelWithFlag 6 | 7 | 8 | @pytest.fixture 9 | def create_comment(): 10 | from siteflags.tests.testapp.models import Comment 11 | 12 | def create_comment_(): 13 | comment = Comment(title='comment%s' % uuid4().hex) 14 | comment.save() 15 | return comment 16 | 17 | return create_comment_ 18 | 19 | 20 | @pytest.fixture 21 | def create_article(): 22 | from siteflags.tests.testapp.models import Article 23 | 24 | def create_article_(): 25 | article = Article(title='article%s' % uuid4().hex) 26 | article.save() 27 | return article 28 | 29 | return create_article_ 30 | 31 | 32 | class TestModelWithFlag: 33 | 34 | def test_get_flags_for_types(self, user, user_create, create_comment, create_article, db_queries): 35 | 36 | from siteflags.tests.testapp.models import Comment, Article 37 | 38 | user2 = user_create() 39 | 40 | article_1 = create_article() 41 | article_2 = create_article() 42 | article_1.set_flag(user) 43 | article_1.set_flag(user2) 44 | article_2.set_flag(user2, status=44) 45 | 46 | flags = ModelWithFlag.get_flags_for_types([Article, Comment]) 47 | assert len(flags) == 2 48 | assert len(flags[Article]) == 3 49 | 50 | comment_1 = create_comment() 51 | comment_2 = create_comment() 52 | comment_1.set_flag(user2) 53 | comment_1.set_flag(user) 54 | comment_2.set_flag(user, status=44) 55 | 56 | flags = ModelWithFlag.get_flags_for_types([Article, Comment]) 57 | assert len(flags) == 2 58 | assert len(flags[Article]) == 3 59 | assert len(flags[Comment]) == 3 60 | 61 | db_queries.clear() 62 | 63 | flags = Article.get_flags_for_type(with_objects=True) 64 | assert len(flags) == 3 65 | 66 | # The following should issue just a single sql. 67 | titles = [flag.linked_object.title for flag in flags] 68 | 69 | assert len(db_queries) == 2 70 | assert len(set(titles)) == 2 71 | 72 | def test_get_flags_for_objects(self, user, user_create, create_article): 73 | user2 = user_create() 74 | 75 | article_1 = create_article() 76 | article_2 = create_article() 77 | article_3 = create_article() 78 | articles_list = (article_1, article_2, article_3) 79 | 80 | article_1.set_flag(user) 81 | article_1.set_flag(user2) 82 | article_2.set_flag(user2, status=33) 83 | 84 | flags = ModelWithFlag.get_flags_for_objects(articles_list) 85 | assert len(flags) == len(articles_list) 86 | assert len(flags[article_1.pk]) == 2 87 | assert len(flags[article_2.pk]) == 1 88 | assert len(flags[article_3.pk]) == 0 89 | 90 | flags = ModelWithFlag.get_flags_for_objects(articles_list, user=user) 91 | assert len(flags) == len(articles_list) 92 | assert len(flags[article_1.pk]) == 1 93 | assert len(flags[article_2.pk]) == 0 94 | assert len(flags[article_3.pk]) == 0 95 | 96 | flags = ModelWithFlag.get_flags_for_objects(articles_list, status=33) 97 | assert len(flags) == len(articles_list) 98 | assert len(flags[article_1.pk]) == 0 99 | assert len(flags[article_2.pk]) == 1 100 | assert len(flags[article_3.pk]) == 0 101 | 102 | def test_set_flag(self, user, create_article): 103 | 104 | flag = create_article().set_flag(user, note='anote', status=10) 105 | 106 | assert flag.user == user 107 | assert flag.note == 'anote' 108 | assert flag.status == 10 109 | 110 | def test_get_flags(self, user, user_create, create_article): 111 | article = create_article() 112 | 113 | for idx in range(1, 5): 114 | article.set_flag(user, status=idx) 115 | 116 | user2 = user_create() 117 | article.set_flag(user2, status=2) 118 | 119 | flags = article.get_flags() 120 | assert len(flags) == 5 121 | 122 | flags = article.get_flags(status=2) 123 | assert len(flags) == 2 124 | 125 | def test_is_flagged(self, user, user_create, create_article): 126 | article = create_article() 127 | assert not article.is_flagged() 128 | 129 | article.set_flag(user, status=11) 130 | assert article.is_flagged() 131 | 132 | user2 = user_create() 133 | 134 | assert article.is_flagged(user) 135 | assert not article.is_flagged(user2) 136 | 137 | assert not article.is_flagged(user, status=12) 138 | assert article.is_flagged(user, status=11) 139 | 140 | user3 = user_create(anonymous=True) 141 | assert not article.is_flagged(user3, status=12) 142 | assert not article.is_flagged(user3, status=11) 143 | 144 | def test_remove_flag(self, user, user_create, create_article): 145 | article = create_article() 146 | article.set_flag(user, status=11) 147 | article.set_flag(user, status=7) 148 | article.set_flag(user, status=13) 149 | user2 = user_create() 150 | article.set_flag(user2, status=11) 151 | article.set_flag(user2, status=13) 152 | user3 = user_create() 153 | article.set_flag(user3, status=11) 154 | 155 | flags = article.get_flags() 156 | assert len(flags) == 6 157 | 158 | article.remove_flag(user3) 159 | flags = article.get_flags() 160 | assert len(flags) == 5 161 | flags = article.get_flags(user3) 162 | assert len(flags) == 0 163 | 164 | article.remove_flag(user, status=13) 165 | flags = article.get_flags(user) 166 | assert len(flags) == 2 167 | 168 | article.remove_flag(status=11) 169 | flags = article.get_flags() 170 | assert len(flags) == 2 171 | 172 | article.remove_flag() 173 | flags = article.get_flags() 174 | assert len(flags) == 0 175 | -------------------------------------------------------------------------------- /siteflags/tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteflags/77503b141ed38f62066f48d99dd9fd9d160c7e1b/siteflags/tests/testapp/__init__.py -------------------------------------------------------------------------------- /siteflags/tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from siteflags.models import ModelWithFlag 4 | 5 | 6 | class Comment(ModelWithFlag): 7 | 8 | title = models.CharField('title', max_length=255) 9 | 10 | 11 | class Article(ModelWithFlag): 12 | 13 | title = models.CharField('title', max_length=255) 14 | -------------------------------------------------------------------------------- /siteflags/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from etc.toolbox import get_model_class_from_settings 4 | 5 | from siteflags import settings 6 | 7 | if False: # pragma: nocover 8 | from .models import Flag # noqa 9 | 10 | 11 | def get_flag_model() -> Type['Flag']: 12 | """Returns the Flag model, set for the project.""" 13 | return get_model_class_from_settings(settings, 'MODEL_FLAG') 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36}-django{20,21,22,30,31,32} 4 | py{37,38,39,310}-django{20,21,22,30,31,32,40} 5 | 6 | install_command = pip install {opts} {packages} 7 | skip_missing_interpreters = True 8 | 9 | [testenv] 10 | commands = python setup.py test 11 | 12 | deps = 13 | django20: Django>=2.0,<2.1 14 | django21: Django>=2.1,<2.2 15 | django22: Django>=2.2,<2.3 16 | django30: Django>=3.0,<3.1 17 | django31: Django>=3.1,<3.2 18 | django32: Django>=3.2,<3.3 19 | django40: Django>=4.0,<4.1 20 | --------------------------------------------------------------------------------