From 3ecd5a4e6a6badae04d57040ee1ba55669eae2ae Mon Sep 17 00:00:00 2001 From: Lova ANDRIARIMALALA <43842786+Xpirix@users.noreply.github.com> Date: Wed, 22 May 2024 17:05:21 +0300 Subject: [PATCH 1/8] Add an aggregate stats to the plugin manager's view (#331) * Add plugin download event per country model * Add country colums to PluginVersionDownload * Add the stats to the plugin page * Fix dockerfile * Add stats url to the env variables * Fix docker-compose.yml * Update env template file --- REQUIREMENTS.txt | 3 ++- dockerize/.env.template | 5 +++- dockerize/docker-compose.yml | 2 ++ dockerize/docker/Dockerfile | 10 +++++++- dockerize/docker/REQUIREMENTS.txt | 3 ++- dockerize/production/Dockerfile | 10 ++++++++ .../migrations/0005_auto_20231214_2317.py | 23 +++++++++++++++++++ .../migrations/0010_merge_20240517_0729.py | 14 +++++++++++ qgis-app/plugins/models.py | 2 ++ .../templates/plugins/plugin_detail.html | 10 ++++++++ qgis-app/plugins/tests/test_download.py | 20 ++++++++++++++-- qgis-app/plugins/utils.py | 9 ++++++++ qgis-app/plugins/views.py | 18 +++++++++++++++ qgis-app/settings.py | 1 + qgis-app/settings_docker.py | 7 +++++- 15 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 qgis-app/plugins/migrations/0005_auto_20231214_2317.py create mode 100644 qgis-app/plugins/migrations/0010_merge_20240517_0729.py diff --git a/REQUIREMENTS.txt b/REQUIREMENTS.txt index a92fb708..c8375f10 100644 --- a/REQUIREMENTS.txt +++ b/REQUIREMENTS.txt @@ -67,4 +67,5 @@ pyjwt==1.7.1 djangorestframework-simplejwt==4.4 django-rest-auth==0.9.5 drf-yasg -django-matomo==0.1.6 \ No newline at end of file +geoip2==4.5.0 +django-matomo==0.1.6 diff --git a/dockerize/.env.template b/dockerize/.env.template index 43f8d6bb..38d4ca75 100644 --- a/dockerize/.env.template +++ b/dockerize/.env.template @@ -31,4 +31,7 @@ DEFAULT_PLUGINS_SITE='https://plugins.qgis.org/' QGISPLUGINS_ENV=debug # Ldap -ENABLE_LDAP=True \ No newline at end of file +ENABLE_LDAP=True + +# Download stats URL +METABASE_DOWNLOAD_STATS_URL='https://plugins.qgis.org/metabase/public/dashboard/' \ No newline at end of file diff --git a/dockerize/docker-compose.yml b/dockerize/docker-compose.yml index 225f9784..387fd96d 100644 --- a/dockerize/docker-compose.yml +++ b/dockerize/docker-compose.yml @@ -46,6 +46,7 @@ services: - ENABLE_LDAP=${ENABLE_LDAP:-False} - RABBITMQ_HOST=${RABBITMQ_HOST:-rabbitmq} - BROKER_URL=amqp://rabbitmq:5672 + - METABASE_DOWNLOAD_STATS_URL=${METABASE_DOWNLOAD_STATS_URL:-/metabase} - EMAIL_BACKEND=${EMAIL_BACKEND} - EMAIL_HOST=${EMAIL_HOST} - EMAIL_PORT=${EMAIL_PORT} @@ -53,6 +54,7 @@ services: - EMAIL_HOST_USER=${EMAIL_HOST_USER:-automation} - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD} - DEFAULT_PLUGINS_SITE=${DEFAULT_PLUGINS_SITE:-https://plugins.qgis.org/} + volumes: - ../qgis-app:/home/web/django_project - ./docker/uwsgi.conf:/uwsgi.conf diff --git a/dockerize/docker/Dockerfile b/dockerize/docker/Dockerfile index dd51c53b..b635df70 100644 --- a/dockerize/docker/Dockerfile +++ b/dockerize/docker/Dockerfile @@ -16,7 +16,15 @@ RUN apt-get update && apt-get install -y \ build-essential \ libffi-dev gdal-bin\ libjpeg-dev libpq-dev \ - liblcms2-dev libblas-dev libatlas-base-dev + liblcms2-dev libblas-dev libatlas-base-dev \ + libmaxminddb0 libmaxminddb-dev mmdb-bin + +# GeoIp mmdb +RUN apt-get update && apt-get install -y curl && curl -LJO https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb && \ + mkdir /var/opt/maxmind && \ + mv GeoLite2-City.mmdb /var/opt/maxmind/GeoLite2-City.mmdb + +ENV GEOIP_PATH=/var/opt/maxmind/ RUN rm -rf /uwsgi.conf ADD dockerize/docker/uwsgi.conf /uwsgi.conf diff --git a/dockerize/docker/REQUIREMENTS.txt b/dockerize/docker/REQUIREMENTS.txt index e9ff83f3..3d738ad9 100644 --- a/dockerize/docker/REQUIREMENTS.txt +++ b/dockerize/docker/REQUIREMENTS.txt @@ -56,6 +56,7 @@ django-rest-multiple-models==2.1.3 django-preferences==1.0.0 PyWavefront==1.3.3 +geoip2==4.5.0 django-matomo==0.1.6 uwsgi~=2.0 -freezegun~=1.4 \ No newline at end of file +freezegun~=1.4 diff --git a/dockerize/production/Dockerfile b/dockerize/production/Dockerfile index 9453fffc..3631f46a 100644 --- a/dockerize/production/Dockerfile +++ b/dockerize/production/Dockerfile @@ -17,6 +17,9 @@ RUN mkdir -p /usr/src; mkdir -p /home/web && \ rm -rf /home/web/django_project && \ ln -s /usr/src/plugins/qgis-app /home/web/django_project +# Install C library for geoip2 +RUN apt-get install -y libmaxminddb0 libmaxminddb-dev mmdb-bin + RUN cd /usr/src/plugins/dockerize/docker && \ pip install --upgrade pip && \ pip install -r REQUIREMENTS.txt && \ @@ -24,6 +27,13 @@ RUN cd /usr/src/plugins/dockerize/docker && \ rm -rf /uwsgi.conf && \ ln -s ${PWD}/uwsgi.conf /uwsgi.conf +# GeoIp mmdb +RUN apt-get update && apt-get install -y curl && curl -LJO https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb && \ + mkdir /var/opt/maxmind && \ + mv GeoLite2-City.mmdb /var/opt/maxmind/GeoLite2-City.mmdb + +ENV GEOIP_PATH=/var/opt/maxmind/ + # Open port 8080 as we will be running our uwsgi socket on that EXPOSE 8080 diff --git a/qgis-app/plugins/migrations/0005_auto_20231214_2317.py b/qgis-app/plugins/migrations/0005_auto_20231214_2317.py new file mode 100644 index 00000000..8cd7045b --- /dev/null +++ b/qgis-app/plugins/migrations/0005_auto_20231214_2317.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.25 on 2023-12-14 23:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0004_merge_20231122_0223'), + ] + + operations = [ + migrations.AddField( + model_name='pluginversiondownload', + name='country_code', + field=models.CharField(default='N/D', max_length=3), + ), + migrations.AddField( + model_name='pluginversiondownload', + name='country_name', + field=models.CharField(default='N/D', max_length=100), + ), + ] diff --git a/qgis-app/plugins/migrations/0010_merge_20240517_0729.py b/qgis-app/plugins/migrations/0010_merge_20240517_0729.py new file mode 100644 index 00000000..54355764 --- /dev/null +++ b/qgis-app/plugins/migrations/0010_merge_20240517_0729.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.13 on 2024-05-17 07:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0005_auto_20231214_2317'), + ('plugins', '0009_merge_20240321_0207'), + ] + + operations = [ + ] diff --git a/qgis-app/plugins/models.py b/qgis-app/plugins/models.py index 72e8cc5a..90245e85 100644 --- a/qgis-app/plugins/models.py +++ b/qgis-app/plugins/models.py @@ -943,6 +943,8 @@ class PluginVersionDownload(models.Model): download_date = models.DateField( default=timezone.now ) + country_code = models.CharField(max_length=3, default='N/D') + country_name = models.CharField(max_length=100, default='N/D') download_count = models.IntegerField( default=0 ) diff --git a/qgis-app/plugins/templates/plugins/plugin_detail.html b/qgis-app/plugins/templates/plugins/plugin_detail.html index a2ffcc37..726360f9 100644 --- a/qgis-app/plugins/templates/plugins/plugin_detail.html +++ b/qgis-app/plugins/templates/plugins/plugin_detail.html @@ -204,6 +204,7 @@

{{ object.name }}
  • {% trans "Versions" %}
  • {% if user.is_staff or user in object.editors %}
  • {% trans "Manage" %}
  • +
  • {% trans "Stats" %}
  • {% endif %} @@ -362,6 +363,15 @@

    {{ object.name }} +
    + +
    {% endif %} {# end admin #} diff --git a/qgis-app/plugins/tests/test_download.py b/qgis-app/plugins/tests/test_download.py index f18f0874..34f204bd 100644 --- a/qgis-app/plugins/tests/test_download.py +++ b/qgis-app/plugins/tests/test_download.py @@ -1,11 +1,11 @@ -from django.test import TestCase, RequestFactory +from django.test import Client, TestCase, RequestFactory from django.contrib.auth.models import User from django.utils import timezone from django.core.files.uploadedfile import SimpleUploadedFile from plugins.models import Plugin, PluginVersion, PluginVersionDownload from plugins.views import version_download - +from django.urls import reverse class TestVersionDownloadView(TestCase): def setUp(self): @@ -50,3 +50,19 @@ def test_version_download(self): self.assertEqual(self.version.downloads, 1) self.assertEqual(self.plugin.downloads, 1) self.assertEqual(download_record.download_count, 1) + + def test_version_download_per_country(self): + download_url = reverse('version_download', args=[self.plugin.package_name, self.version.version]) + c = Client(REMOTE_ADDR='180.247.213.170') + response = c.get(download_url) + + self.version.refresh_from_db() + self.plugin.refresh_from_db() + download_record = PluginVersionDownload.objects.get( + plugin_version=self.version, + download_date=timezone.now().date() + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(download_record.country_code == 'ID') + self.assertTrue(download_record.country_name == 'Indonesia') \ No newline at end of file diff --git a/qgis-app/plugins/utils.py b/qgis-app/plugins/utils.py index e180e22b..521a5797 100644 --- a/qgis-app/plugins/utils.py +++ b/qgis-app/plugins/utils.py @@ -1,5 +1,6 @@ import requests import re +from django.http import HttpRequest def extract_version(tag): @@ -47,3 +48,11 @@ def get_qgis_versions(): if version not in all_versions: all_versions.append(version) return all_versions + + +def parse_remote_addr(request: HttpRequest) -> str: + """Extract client IP from request.""" + x_forwarded_for = request.headers.get("X-Forwarded-For", "") + if x_forwarded_for: + return x_forwarded_for.split(",")[0] + return request.META.get("REMOTE_ADDR", "") \ No newline at end of file diff --git a/qgis-app/plugins/views.py b/qgis-app/plugins/views.py index 20dfca1c..9f8766a4 100644 --- a/qgis-app/plugins/views.py +++ b/qgis-app/plugins/views.py @@ -34,6 +34,8 @@ from plugins.forms import * from plugins.models import Plugin, PluginOutstandingToken, PluginVersion, PluginVersionDownload, vjust from plugins.validator import PLUGIN_REQUIRED_METADATA +from django.contrib.gis.geoip2 import GeoIP2 +from plugins.utils import parse_remote_addr from rest_framework_simplejwt.token_blacklist.models import OutstandingToken from rest_framework_simplejwt.tokens import RefreshToken, api_settings @@ -543,8 +545,10 @@ def get_context_data(self, **kwargs): "%s metadata is missing, this metadata entry is required. Please add %s to metadata.txt." ) % (md, md) messages.error(self.request, msg, fail_silently=True) + stats_url = f"{settings.METABASE_DOWNLOAD_STATS_URL}?package_name={plugin.package_name}#hide_parameters=package_name" context.update( { + "stats_url": stats_url, "rating": plugin.rating.get_rating(), "votes": plugin.rating.votes, } @@ -1519,8 +1523,22 @@ def version_download(request, package_name, version): plugin.downloads = plugin.downloads + 1 plugin.save(keep_date=True) + remote_addr = parse_remote_addr(request) + g = GeoIP2() + + if remote_addr: + try: + country_data = g.country(remote_addr) + country_code = country_data['country_code'] + country_name = country_data['country_name'] + except Exception as e: # AddressNotFoundErrors: + country_code = 'N/D' + country_name = 'N/D' + download_record, created = PluginVersionDownload.objects.get_or_create( plugin_version = version, + country_code = country_code, + country_name = country_name, download_date = now().date(), defaults = {'download_count': 1} ) diff --git a/qgis-app/settings.py b/qgis-app/settings.py index 23d50828..16d3cb2d 100644 --- a/qgis-app/settings.py +++ b/qgis-app/settings.py @@ -335,6 +335,7 @@ CELERY_BROKER_URL = BROKER_URL CELERY_RESULT_BACKEND = CELERY_BROKER_URL +GEOIP_PATH='/var/opt/maxmind/' # Token access and refresh validity SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(days=15), diff --git a/qgis-app/settings_docker.py b/qgis-app/settings_docker.py index c0b7be1e..9d8fcacf 100644 --- a/qgis-app/settings_docker.py +++ b/qgis-app/settings_docker.py @@ -134,6 +134,11 @@ "TEST_REQUEST_DEFAULT_FORMAT": "json", } +GEOIP_PATH='/var/opt/maxmind/' +METABASE_DOWNLOAD_STATS_URL = os.environ.get( + "METABASE_DOWNLOAD_STATS_URL", + "/metabase" +) CELERY_RESULT_BACKEND = 'rpc://' CELERY_BROKER_URL = os.environ.get('BROKER_URL', 'amqp://rabbitmq:5672') CELERY_BEAT_SCHEDULE = { @@ -159,4 +164,4 @@ MATOMO_URL="//matomo.qgis.org/" # Default primary key type -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' \ No newline at end of file +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' From 9fe81ad972cbea2f37107ea96e69cd20143f4813 Mon Sep 17 00:00:00 2001 From: Lova ANDRIARIMALALA <43842786+Xpirix@users.noreply.github.com> Date: Wed, 22 May 2024 17:20:01 +0300 Subject: [PATCH 2/8] Explicitly list invalid metadata keys (#380) * List all invalid keys metadata * Update test for metadata url validator * Use inital function name for _check_url_link --- qgis-app/plugins/tests/test_validator.py | 8 +- qgis-app/plugins/validator.py | 116 ++++++++++++----------- 2 files changed, 65 insertions(+), 59 deletions(-) diff --git a/qgis-app/plugins/tests/test_validator.py b/qgis-app/plugins/tests/test_validator.py index 6b1e9051..a4261d88 100644 --- a/qgis-app/plugins/tests/test_validator.py +++ b/qgis-app/plugins/tests/test_validator.py @@ -114,13 +114,13 @@ def test_invalid_metadata_web_does_not_exist(self): @mock.patch("requests.get", side_effect=requests.exceptions.SSLError()) def test_check_url_link_ssl_error(self, mock_request): - url = "http://example.com/" - self.assertIsNone(_check_url_link(url, "forbidden_url", "metadata attribute")) + urls = [{'url': "http://example.com/", 'forbidden_url': "forbidden_url", 'metadata_attr': "metadata attribute"}] + self.assertIsNone(_check_url_link(urls)) @mock.patch("requests.get", side_effect=requests.exceptions.HTTPError()) def test_check_url_link_does_not_exist(self, mock_request): - url = "http://example.com/" - self.assertIsNone(_check_url_link(url, "forbidden_url", "metadata attribute")) + urls = [{'url': "http://example.com/", 'forbidden_url': "forbidden_url", 'metadata_attr': "metadata attribute"}] + self.assertIsNone(_check_url_link(urls)) class TestValidatorForbiddenFileFolder(TestCase): diff --git a/qgis-app/plugins/validator.py b/qgis-app/plugins/validator.py index f8ebd6ad..a31c5e72 100644 --- a/qgis-app/plugins/validator.py +++ b/qgis-app/plugins/validator.py @@ -86,65 +86,68 @@ def _check_required_metadata(metadata): """ Checks if required metadata are in place, raise ValidationError if not found """ - for md in PLUGIN_REQUIRED_METADATA: - if md not in dict(metadata) or not dict(metadata)[md]: - raise ValidationError( - _( - 'Cannot find metadata %s in metadata source %s.
    For further informations about metadata, please see: metadata documentation' - ) - % (md, dict(metadata).get("metadata_source")) - ) + missing_fields = [field for field in PLUGIN_REQUIRED_METADATA if field not in [item[0] for item in metadata]] + if len(missing_fields) > 0: + missing_fields_str = ', '.join(missing_fields) + raise ValidationError( + _( + f'Cannot find metadata {missing_fields_str} in metadata source {dict(metadata).get("metadata_source")}.
    For further informations about metadata, please see: metadata documentation' + ) + ) -def _check_url_link(url: str, forbidden_url: str, metadata_attr: str) -> None: +def _check_url_link(urls): """ - Checks if the url link is valid. + Checks if all the url link is valid. """ - error_check = ValidationError( - _("Please provide valid url link for %s in metadata.") % metadata_attr + def error_check(url: str, forbidden_url: str)->bool: + # Check against forbidden_url + if url == forbidden_url: + return True + + # Check if parsed URL is valid + try: + parsed_url = urlparse(url) + return not all([parsed_url.scheme, parsed_url.netloc]) + except Exception as e: + # Log the exception or handle it as per your requirement + print(f"Error occurred: {e}") + return True + + def error_check_if_exist(url: str)->bool: + # Check if url is exist + try: + # https://stackoverflow.com/a/41950438/10268058 + # add the headers parameter to make the request appears like coming + # from browser, otherwise some websites will return 403 + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/56.0.2924.76 Safari/537.36" + } + req = requests.head(url, headers=headers) + except requests.exceptions.SSLError: + req = requests.head(url, verify=False) + except Exception: + return True + return req.status_code >= 400 + + url_error = [item for item in [url_item['metadata_attr'] for url_item in urls if error_check(url_item['url'], url_item['forbidden_url'])]] + if len(url_error) > 0: + url_error_str = ", ".join(url_error) + raise ValidationError( + _(f"Please provide valid url link for the following key(s) in the metadata source: {url_error_str}. ") ) - error_check_if_exist = ValidationError( + exist_url_error = [item for item in [url_item['metadata_attr'] for url_item in urls if error_check_if_exist(url_item['url'])]] + if len(exist_url_error) > 0: + exist_url_error_str = ", ".join(exist_url_error) + raise ValidationError( _( - "Please provide valid url link for %s in metadata. " - "This website cannot be reached." - ) - % metadata_attr + f"Please provide valid url link for the following key(s) in the metadata source: {exist_url_error_str}. " + "The website(s) cannot be reached." + ) ) - # check against forbidden_url - is_forbidden_url = url == forbidden_url - if is_forbidden_url: - raise error_check - - # check if parsed url is valid - # https://stackoverflow.com/a/38020041 - try: - parsed_url = urlparse(url) # e.g https://plugins.qgis.org/ - if not ( - all([parsed_url.scheme, parsed_url.netloc]) # e.g http - ): # e.g www.qgis.org - raise error_check - except Exception: - raise error_check - - # Check if url is exist - try: - # https://stackoverflow.com/a/41950438/10268058 - # add the headers parameter to make the request appears like coming - # from browser, otherwise some websites will return 403 - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/56.0.2924.76 Safari/537.36" - } - req = requests.head(url, headers=headers) - except requests.exceptions.SSLError: - req = requests.head(url, verify=False) - except Exception: - raise error_check_if_exist - if req.status_code >= 400: - raise error_check_if_exist - def validator(package): """ @@ -329,11 +332,14 @@ def validator(package): ) % (min_qgs_version, ",".join(e.messages)) ) - # check url_link - _check_url_link(dict(metadata).get("tracker"), "http://bugs", "Bug tracker") - _check_url_link(dict(metadata).get("repository"), "http://repo", "Repository") - _check_url_link(dict(metadata).get("homepage"), "http://homepage", "Home page") + urls_to_check = [ + {'url': dict(metadata).get("tracker"), 'forbidden_url': "http://bugs", 'metadata_attr': "tracker"}, + {'url': dict(metadata).get("repository"), 'forbidden_url': "http://repo", 'metadata_attr': "repository"}, + {'url': dict(metadata).get("homepage"), 'forbidden_url': "http://homepage", 'metadata_attr': "homepage"}, + ] + + _check_url_link(urls_to_check) # Checks for LICENCE file presence From a791c6afe24ba335278ff10297c8dc9d093ea255 Mon Sep 17 00:00:00 2001 From: Lova ANDRIARIMALALA <43842786+Xpirix@users.noreply.github.com> Date: Wed, 22 May 2024 17:20:31 +0300 Subject: [PATCH 3/8] Fix responsive navbar (#385) --- qgis-app/templates/base.html | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/qgis-app/templates/base.html b/qgis-app/templates/base.html index a702adc1..c8014abb 100644 --- a/qgis-app/templates/base.html +++ b/qgis-app/templates/base.html @@ -59,7 +59,7 @@ {% get_namedmenu Navigation as menu %} -