├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── django_visit_count ├── __init__.py ├── app_settings.py ├── locale │ └── fa │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── mixins.py └── utils.py ├── pyproject.toml └── setup.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | max_line_length = 120 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | charset = utf-8 13 | 14 | # Do not add "md" here! It breaks Markdown re-formatting in PyCharm. 15 | [*.{json,yml,yaml,html,md}] 16 | indent_size = 2 17 | 18 | [*.md] 19 | # Shorter lines in documentation files improves readability 20 | max_line_length = 80 21 | # 2 spaces at the end of a line forces a line break in MarkDown 22 | trim_trailing_whitespace = false 23 | 24 | [Makefile] 25 | indent_style = tab 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | formatting: 7 | name: Check Black Formatting 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 3.12 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: "3.12" 15 | - name: Install black 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install black 19 | - name: Check code formatting with black 20 | run: | 21 | black -l 120 --check --diff --color django_visit_count setup.py 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .*cache 4 | .coverage 5 | .python-version 6 | .idea/ 7 | .tox/ 8 | build/ 9 | dist/ 10 | htmlcov 11 | venv/ 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: "v5.0.0" 6 | hooks: 7 | - id: trailing-whitespace # trims trailing whitespace 8 | args: [--markdown-linebreak-ext=md] 9 | - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline 10 | - id: check-yaml # checks syntax of yaml files 11 | - id: check-json # checks syntax of json files 12 | - id: check-added-large-files # prevent giant files from being committed 13 | - id: fix-encoding-pragma # removes "# -*- coding: utf-8 -*-" from python files (since we only support python 3) 14 | args: [--remove] 15 | - id: check-merge-conflict # check for files that contain merge conflict strings 16 | 17 | - repo: https://github.com/adamchainz/django-upgrade 18 | rev: "1.23.1" 19 | hooks: 20 | - id: django-upgrade 21 | args: [--target-version, "3.2"] 22 | 23 | - repo: https://github.com/asottile/pyupgrade 24 | rev: "v3.19.1" 25 | hooks: 26 | - id: pyupgrade 27 | args: [--py37-plus] 28 | 29 | - repo: https://github.com/pycqa/isort 30 | rev: "6.0.0" 31 | hooks: 32 | - id: isort 33 | name: isort (python) 34 | 35 | - repo: https://github.com/psf/black 36 | rev: "25.1.0" 37 | hooks: 38 | - id: black 39 | 40 | - repo: https://github.com/ikamensh/flynt 41 | rev: "1.0.1" 42 | hooks: 43 | - id: flynt 44 | args: [--aggressive, --line-length, "120"] 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mohammad Javad Naderi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.txt 3 | recursive-include django_visit_count/locale * 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Visit Count 2 | 3 | [![](https://img.shields.io/pypi/v/django-visit-count.svg)](https://pypi.python.org/pypi/django-visit-count/) 4 | [![](https://img.shields.io/github/license/QueraTeam/django-visit-count.svg)](https://github.com/QueraTeam/django-visit-count/blob/master/LICENSE) 5 | [![](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | 7 | Count visits using cache for Django models. 8 | 9 | ## Installation 10 | 11 | 1. [Set-up](https://docs.djangoproject.com/en/dev/topics/cache/#setting-up-the-cache) Django's cache framework. 12 | 13 | 2. Install the Python package. 14 | 15 | ``` 16 | pip install django_visit_count 17 | ``` 18 | 19 | ## Usage 20 | 21 | Use `VisitCountMixin`. It adds a `visit_count` field to your model. 22 | 23 | ```python 24 | from django_visit_count.mixins import VisitCountMixin 25 | 26 | class MyBlogPost(VisitCountMixin, models.Model): 27 | ... 28 | ``` 29 | 30 | Create and run migrations on your model. 31 | 32 | ```shell 33 | $ python manage.py makemigrations my_blog_app 34 | $ python manage.py migrate my_blog_app 35 | ``` 36 | 37 | Count visits in your view like this: 38 | 39 | ```python 40 | def view_blog_post(request, post_id): 41 | post = get_object_or_404(MyBlogPost, pk=post_id) 42 | post.count_visit(request) 43 | ... 44 | ``` 45 | 46 | ## Advanced Usage 47 | 48 | If you need more control, you can use `is_new_visit` function. 49 | 50 | ```python 51 | class MyBlogPost(models.Model): 52 | total_visits = models.PositiveIntegerField(default=0) 53 | ... 54 | ``` 55 | 56 | ```python 57 | from django_visit_count.utils import is_new_visit 58 | 59 | def view_blog_post(request, post_id): 60 | post = get_object_or_404(MyBlogPost, pk=post_id) 61 | 62 | if is_new_visit(request, post): 63 | post.total_visits = F("total_visits") + 1 64 | post.save(update_fields=["total_visits"]) 65 | 66 | ... 67 | ``` 68 | 69 | You can pass an optional keyword argument `session_duration` (integer, number of seconds) 70 | to `count_visit` or `is_new_visit`. 71 | 72 | ## Settings 73 | 74 | Default settings: 75 | 76 | ```python 77 | VISIT_COUNT_DEFAULT_SESSION_DURATION = 5 * 60 # seconds 78 | ``` 79 | 80 | ## Development 81 | 82 | - Install development dependencies in your virtualenv with `pip install -e '.[dev]'` 83 | - Install pre-commit hooks using `pre-commit install`. 84 | 85 | ## License 86 | 87 | MIT 88 | -------------------------------------------------------------------------------- /django_visit_count/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QueraTeam/django-visit-count/073b957ef6a102a783d83723696ecb4f66c6eeb9/django_visit_count/__init__.py -------------------------------------------------------------------------------- /django_visit_count/app_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | 5 | VISIT_COUNT_DEFAULT_SESSION_DURATION = getattr(settings, "VISIT_COUNT_DEFAULT_SESSION_DURATION", 5 * 60) 6 | -------------------------------------------------------------------------------- /django_visit_count/locale/fa/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QueraTeam/django-visit-count/073b957ef6a102a783d83723696ecb4f66c6eeb9/django_visit_count/locale/fa/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_visit_count/locale/fa/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 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: PACKAGE VERSION\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2022-09-13 12:14+0430\n" 10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 11 | "Last-Translator: FULL NAME \n" 12 | "Language-Team: LANGUAGE \n" 13 | "Language: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 18 | 19 | msgid "Visit count" 20 | msgstr "تعداد بازدید" 21 | -------------------------------------------------------------------------------- /django_visit_count/mixins.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .app_settings import VISIT_COUNT_DEFAULT_SESSION_DURATION 5 | from .utils import is_new_visit 6 | 7 | 8 | class VisitCountMixin(models.Model): 9 | visit_count = models.PositiveIntegerField(default=0, help_text=_("Visit count")) 10 | 11 | class Meta: 12 | abstract = True 13 | 14 | def count_visit(self, request, session_duration=VISIT_COUNT_DEFAULT_SESSION_DURATION): 15 | if is_new_visit(request, self, session_duration=session_duration): 16 | self.visit_count = models.F("visit_count") + 1 17 | self.save(update_fields=["visit_count"]) 18 | 19 | def reset_visits(self, save=True): 20 | self.visit_count = 0 21 | if save: 22 | self.save(update_fields=["visit_count"]) 23 | -------------------------------------------------------------------------------- /django_visit_count/utils.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | 3 | from .app_settings import VISIT_COUNT_DEFAULT_SESSION_DURATION 4 | 5 | 6 | def is_new_visit(request, obj, *, session_duration=VISIT_COUNT_DEFAULT_SESSION_DURATION): 7 | if request.user.is_authenticated: 8 | user_key = f"user-{request.user.id}" 9 | else: 10 | google_analytics_id = request.COOKIES.get("_gid", request.COOKIES.get("_ga", "")) 11 | user_key = f"{request.META['REMOTE_ADDR']}-{google_analytics_id[:120]}" 12 | 13 | object_key = f"{obj.__class__.__name__}-{getattr(obj, 'pk', obj)}" 14 | 15 | cache_key = f"__visit__:{object_key}:{user_key}" 16 | 17 | if cache.get(cache_key): 18 | return False 19 | 20 | cache.set(cache_key, True, timeout=session_duration) 21 | return True 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | include = '\.pyi?$' 4 | exclude = '/\..+/' 5 | 6 | [tool.isort] 7 | profile = "black" 8 | line_length = 120 9 | skip_gitignore = true 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | with open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="UTF-8") as readme: 6 | README = readme.read() 7 | 8 | # allow setup.py to be run from any path 9 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 10 | 11 | dev_requirements = [ 12 | "pre-commit", 13 | ] 14 | 15 | setup( 16 | name="django-visit-count", 17 | version="1.2.1", 18 | description="Count visits using cache for Django models", 19 | long_description=README, 20 | long_description_content_type="text/markdown", 21 | author="Mohammad Javad Naderi", 22 | url="https://github.com/QueraTeam/django-visit-count", 23 | download_url="https://pypi.python.org/pypi/django-visit-count", 24 | license="MIT", 25 | packages=find_packages(".", include=("django_visit_count", "django_visit_count.*")), 26 | include_package_data=True, 27 | install_requires=["Django>=3.2"], 28 | extras_require={"dev": dev_requirements}, 29 | tests_require=dev_requirements, 30 | classifiers=[ 31 | "Development Status :: 5 - Production/Stable", 32 | "Environment :: Web Environment", 33 | "Framework :: Django", 34 | "Framework :: Django :: 3.2", 35 | "Framework :: Django :: 4.0", 36 | "Framework :: Django :: 4.1", 37 | "Intended Audience :: Developers", 38 | "License :: OSI Approved :: MIT License", 39 | "Operating System :: OS Independent", 40 | "Programming Language :: Python", 41 | "Programming Language :: Python :: 3.7", 42 | "Programming Language :: Python :: 3.8", 43 | "Programming Language :: Python :: 3.9", 44 | "Programming Language :: Python :: 3.10", 45 | ], 46 | ) 47 | --------------------------------------------------------------------------------