diff --git a/REQUIREMENTS.txt b/REQUIREMENTS.txt index 783d71a7..f02fdf0e 100644 --- a/REQUIREMENTS.txt +++ b/REQUIREMENTS.txt @@ -13,8 +13,12 @@ Markdown==2.3.1 #PIL==1.1.7 Pillow Pygments==2.7.4 -git+https://github.com/Xpirix/whoosh.git + +# Updates for Django 2 & Python 3.7 +git+https://github.com/Xpirix/whoosh.git@a306553 pickle5==0.0.12 +django-haystack==3.2.1 + argparse==1.2.1 #cab==0.2.0 # Not used anymore..- #distribute==0.7.3 @@ -25,7 +29,6 @@ django-debug-toolbar==3.2.4 django-endless-pagination==2.0 django-extensions==1.2.0 django-generic-aggregation==0.3.2 -django-haystack==2.4.0 #django-olwidget==0.61.0 unmaintained, use this fork git+https://github.com/Christophe31/olwidget.git django-pagination==1.0.7 @@ -37,7 +40,7 @@ git+https://github.com/gelo-zhukov/django-ratings.git django-simple-ratings==0.3.2 # SIMPLEMENU git+https://github.com/elpaso/django-simplemenu.git -django-taggit==0.14.0 +django-taggit==2.0.0 django-taggit-autosuggest==0.2.7 django-taggit-templatetags==0.4.6dev0 django-templatetag-sugar==0.1 @@ -60,5 +63,8 @@ django-bootstrap-pagination==1.7.1 django-sortable-listview==0.43 django-user-map djangorestframework==3.12.2 +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 diff --git a/dockerize/docker-compose.yml b/dockerize/docker-compose.yml index 72c1dc14..102bb6cb 100644 --- a/dockerize/docker-compose.yml +++ b/dockerize/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3' +version: '3.8' volumes: backups-data: static-data: diff --git a/dockerize/docker/Dockerfile b/dockerize/docker/Dockerfile index f7f77638..9e43d497 100644 --- a/dockerize/docker/Dockerfile +++ b/dockerize/docker/Dockerfile @@ -16,7 +16,8 @@ ADD dockerize/docker/uwsgi.conf /uwsgi.conf ADD qgis-app /home/web/django_project ADD dockerize/docker/REQUIREMENTS.txt /REQUIREMENTS.txt RUN pip install --upgrade pip && pip install -r /REQUIREMENTS.txt -RUN pip install uwsgi +RUN pip install uwsgi freezegun==1.3.1 + # Open port 8080 as we will be running our uwsgi socket on that EXPOSE 8080 diff --git a/dockerize/docker/REQUIREMENTS.txt b/dockerize/docker/REQUIREMENTS.txt index 85e59976..5fafb8b6 100644 --- a/dockerize/docker/REQUIREMENTS.txt +++ b/dockerize/docker/REQUIREMENTS.txt @@ -1,7 +1,7 @@ django==3.2.11 django-auth-ldap python-ldap -django-taggit +django-taggit==2.0.0 django-tinymce==3.4.0 psycopg2 # Updates for Django 2 @@ -22,12 +22,13 @@ django-bootstrap-pagination django-sortable-listview sorl-thumbnail django-extensions + django-debug-toolbar==3.2.4 -# Updates for Django 3 & Python 3.7 -git+https://github.com/Xpirix/whoosh.git +# Updates for Django 2 & Python 3.7 +git+https://github.com/Xpirix/whoosh.git@a306553 pickle5==0.0.12 -django-haystack +django-haystack==3.2.1 # Feedjack==0.9.18 # So use George's fork rather @@ -50,6 +51,9 @@ requests==2.23.0 markdown==3.2.1 djangorestframework==3.11.2 +pyjwt==1.7.1 +djangorestframework-simplejwt==4.4 + sorl-thumbnail-serializer-field==0.2.1 django-rest-auth==0.9.5 drf-yasg==1.17.1 @@ -57,3 +61,4 @@ django-rest-multiple-models==2.1.3 django-preferences==1.0.0 PyWavefront==1.3.3 +django-matomo==0.1.6 diff --git a/qgis-app/REQUIREMENTS_plugins.txt b/qgis-app/REQUIREMENTS_plugins.txt index ab719d94..40e69142 100644 --- a/qgis-app/REQUIREMENTS_plugins.txt +++ b/qgis-app/REQUIREMENTS_plugins.txt @@ -1,7 +1,7 @@ django==3.2.11 django-auth-ldap python-ldap -django-taggit +django-taggit==2.0.0 django-tinymce==3.4.0 psycopg2 # Updates for Django 2 @@ -24,7 +24,7 @@ sorl-thumbnail django-extensions django-debug-toolbar==3.2.4 -# Updates for Django 3 & Python 3.7 -git+https://github.com/Xpirix/whoosh.git +# Updates for Django 2 & Python 3.7 +git+https://github.com/Xpirix/whoosh.git@a306553 pickle5==0.0.12 -django-haystack +django-haystack==3.2.1 diff --git a/qgis-app/base/views/processing_view.py b/qgis-app/base/views/processing_view.py index c0fcbde3..bddb4fb5 100644 --- a/qgis-app/base/views/processing_view.py +++ b/qgis-app/base/views/processing_view.py @@ -28,6 +28,7 @@ View, ) from django.views.generic.base import ContextMixin +from django.utils.encoding import escape_uri_path GROUP_NAME = "Style Managers" @@ -306,6 +307,7 @@ def get_context_data(self, **kwargs): context["reviewer"] = reviewer if user.is_staff or is_resources_manager(user): context["form"] = ResourceBaseReviewForm(resource_name=self.resource_name) + context["is_style_manager"] = is_resources_manager(user) if self.is_3d_model: context["url_viewer"] = "%s_viewer" % self.resource_name_url_base return context @@ -484,9 +486,8 @@ def get(self, request, *args, **kwargs): response = HttpResponse( zipfile.getvalue(), content_type="application/x-zip-compressed" ) - response["Content-Disposition"] = "attachment; filename=%s.zip" % ( - slugify(object.name, allow_unicode=True) - ) + zip_name = slugify(object.name, allow_unicode=True) + response["Content-Disposition"] = f"attachment; filename*=utf-8''{escape_uri_path(zip_name)}.zip" return response diff --git a/qgis-app/fixtures/auth.json b/qgis-app/fixtures/auth.json index 16588237..24af50b9 100644 --- a/qgis-app/fixtures/auth.json +++ b/qgis-app/fixtures/auth.json @@ -12,7 +12,7 @@ "last_login": "2010-11-24 07:56:12", "groups": [], "user_permissions": [], - "password": "sha1$d6c11$4f3f04e104dc8bbe7950234f0cd8406a65df0bdf", + "password": "pbkdf2_sha256$150000$foQAQGi54z25$AQelhq+oBE3TOBJRT9F9UsEP5K1PSWQnQeozkmyc3fs=", "email": "", "date_joined": "2010-11-24 07:56:12" } @@ -30,7 +30,7 @@ "last_login": "2010-11-25 07:35:07", "groups": [], "user_permissions": [], - "password": "sha1$9ba9f$6088ef8abc2243a55e777e937159c8f2fd4920bb", + "password": "pbkdf2_sha256$150000$BBba4NloaWZO$XN4lzpxcvFSrLl1QqiwQz/0ZLiEH/JTgEJE/uRRXWto=", "email": "admin@admin.it", "date_joined": "2009-10-06 18:04:20" } @@ -48,7 +48,7 @@ "last_login": "2010-11-25 07:35:20", "groups": [], "user_permissions": [], - "password": "sha1$cb97a$221727796b3f551e342dca9d00112f072e399182", + "password": "pbkdf2_sha256$150000$GJga5YEinaWz$zJAjCXccvWHNPGmoZEjvBNgm1DGkjZGA3BmTVaNAxP4=", "email": "", "date_joined": "2010-11-25 07:35:20" } diff --git a/qgis-app/plugins/decorators.py b/qgis-app/plugins/decorators.py new file mode 100644 index 00000000..69509b70 --- /dev/null +++ b/qgis-app/plugins/decorators.py @@ -0,0 +1,39 @@ +from functools import wraps +from django.http import HttpResponseForbidden +from rest_framework_simplejwt.authentication import JWTAuthentication +from plugins.models import Plugin, PluginOutstandingToken +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken +import datetime + +def has_valid_token(function): + @wraps(function) + def wrap(request, *args, **kwargs): + auth_token = request.META.get("HTTP_AUTHORIZATION") + package_name = kwargs.get('package_name') + if not str(auth_token).startswith('Bearer'): + raise InvalidToken("Invalid token") + + # Validate JWT token + authentication = JWTAuthentication() + try: + validated_token = authentication.get_validated_token(auth_token[7:]) + plugin_id = validated_token.payload.get('plugin_id') + jti = validated_token.payload.get('refresh_jti') + token_id = OutstandingToken.objects.get(jti=jti).pk + is_blacklisted = BlacklistedToken.objects.filter(token_id=token_id).exists() + if not plugin_id or is_blacklisted: + raise InvalidToken("Invalid token") + + plugin = Plugin.objects.get(pk=plugin_id) + if not plugin or plugin.package_name != package_name: + raise InvalidToken("Invalid token") + plugin_token = PluginOutstandingToken.objects.get(token__pk=token_id, plugin=plugin) + plugin_token.last_used_on = datetime.datetime.now() + plugin_token.save() + request.plugin_token = plugin_token + return function(request, *args, **kwargs) + except (InvalidToken, TokenError) as e: + return HttpResponseForbidden(str(e)) + + return wrap diff --git a/qgis-app/plugins/forms.py b/qgis-app/plugins/forms.py index 600986de..40c8ab5a 100644 --- a/qgis-app/plugins/forms.py +++ b/qgis-app/plugins/forms.py @@ -6,7 +6,7 @@ from django.forms import CharField, ModelForm, ValidationError from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ -from plugins.models import Plugin, PluginVersion, PluginVersionFeedback +from plugins.models import Plugin, PluginOutstandingToken, PluginVersion, PluginVersionFeedback from plugins.validator import validator from taggit.forms import * @@ -43,10 +43,25 @@ class Meta: "tracker", "repository", "owners", + "maintainer", + "display_created_by", "tags", "server", ) + def __init__(self, *args, **kwargs): + super(PluginForm, self).__init__(*args, **kwargs) + self.fields['owners'].label = "Collaborators" + + choices = ( + (self.instance.created_by.pk, self.instance.created_by.username + " (Plugin creator)"), + ) + for owner in self.instance.owners.exclude(pk=self.instance.created_by.pk): + choices += ((owner.pk, owner.username + " (Collaborator)"),) + + self.fields['maintainer'].choices = choices + self.fields['maintainer'].label = "Maintainer" + def clean(self): """ Check author @@ -244,3 +259,14 @@ def clean(self): self.cleaned_data['tasks'] = tasks return self.cleaned_data + +class PluginTokenForm(ModelForm): + """ + Form for token description editing + """ + + class Meta: + model = PluginOutstandingToken + fields = ( + "description", + ) \ No newline at end of file diff --git a/qgis-app/plugins/management/commands/cleanmediafolder.py b/qgis-app/plugins/management/commands/cleanmediafolder.py new file mode 100644 index 00000000..da18bda9 --- /dev/null +++ b/qgis-app/plugins/management/commands/cleanmediafolder.py @@ -0,0 +1,38 @@ +import os +from shutil import rmtree +from django.conf import settings +from django.core.management import call_command +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + help = 'Run collectstatic and delete folders from media that exist in static' + + def handle(self, *args, **options): + confirm = input("Do you want to run 'collectstatic' first? (yes/no): ") + if confirm.lower() == 'yes': + # Run collectstatic command + call_command('collectstatic', interactive=False) + + # Get the paths of static and media folders + static_root = settings.STATIC_ROOT + media_root = settings.MEDIA_ROOT + + # Iterate over each directory in the static folder + for static_dir in os.listdir(static_root): + static_path = os.path.join(static_root, static_dir) + + # Check if the path is a directory and exists in the media folder + if os.path.isdir(static_path) and os.path.exists(os.path.join(media_root, static_dir)): + confirm = input(f"Are you sure you want to delete '{static_dir}' from the media folder? (yes/no): ") + + if confirm.lower() == 'yes': + try: + # Delete the corresponding folder in the media folder + rmtree(os.path.join(media_root, static_dir)) + self.stdout.write(self.style.SUCCESS(f'Deleted {static_dir} from media folder.')) + except Exception as e: + self.stderr.write(self.style.ERROR(f'Error deleting {static_dir}: {str(e)}')) + else: + self.stdout.write(self.style.WARNING(f'Skipped deletion of {static_dir}.')) + + self.stdout.write(self.style.SUCCESS('The media folder has been cleaned.')) diff --git a/qgis-app/plugins/management/commands/organize_old_package.py b/qgis-app/plugins/management/commands/organize_old_package.py new file mode 100644 index 00000000..991ed958 --- /dev/null +++ b/qgis-app/plugins/management/commands/organize_old_package.py @@ -0,0 +1,40 @@ +# myapp/management/commands/organize_packages.py +import os +import shutil +from django.core.management.base import BaseCommand +from django.conf import settings +from plugins.models import PluginVersion + +PLUGINS_STORAGE_PATH = getattr(settings, "PLUGINS_STORAGE_PATH", "packages") +class Command(BaseCommand): + help = 'Organize packages created before 2014 into folders by year' + + def handle(self, *args, **options): + packages_dir = os.path.join(settings.MEDIA_ROOT, PLUGINS_STORAGE_PATH) + + # Some of the packages created on 2014 also need to be organized + versions = PluginVersion.objects.filter(created_on__lt='2014-12-31').exclude(package__icontains='2014/') + self.stdout.write(self.style.NOTICE(f'{versions.count()} packages will be organized.')) + + for version in versions: + year_folder = os.path.join(packages_dir, str(version.created_on.year)) + + # Create the year folder if it doesn't exist + os.makedirs(year_folder, exist_ok=True) + + # Copy the package file to the year folder + old_path = version.package.path + if os.path.exists(old_path): + new_path = os.path.join(year_folder, os.path.basename(old_path)) + if not os.path.exists(new_path): + shutil.copy(old_path, year_folder) + + # Update the model with the new package path + version.package.name = os.path.relpath(new_path, settings.MEDIA_ROOT) + version.save() + else: + self.stdout.write(self.style.WARNING(f'Plugin version id {version.pk} ignored: {new_path} already exists.')) + else: + self.stdout.write(self.style.WARNING(f'Plugin version id {version.pk} ignored: {old_path} is not found.')) + + self.stdout.write(self.style.SUCCESS('Packages organized successfully')) diff --git a/qgis-app/plugins/middleware.py b/qgis-app/plugins/middleware.py index 059a7848..17adb828 100644 --- a/qgis-app/plugins/middleware.py +++ b/qgis-app/plugins/middleware.py @@ -2,8 +2,7 @@ # Author: A. Pasotti from django.contrib import auth -from django.contrib.auth.models import User - +from rest_framework_simplejwt.authentication import JWTAuthentication def HttpAuthMiddleware(get_response): """ @@ -12,7 +11,7 @@ def HttpAuthMiddleware(get_response): def middleware(request): auth_basic = request.META.get("HTTP_AUTHORIZATION") - if auth_basic: + if auth_basic and not str(auth_basic).startswith('Bearer'): import base64 username, dummy, password = base64.decodestring( @@ -27,7 +26,6 @@ def middleware(request): # by logging the user in. request.user = user auth.login(request, user) - response = get_response(request) # Code to be executed for each request/response after diff --git a/qgis-app/plugins/migrations/0004_merge_20231122_0223.py b/qgis-app/plugins/migrations/0004_merge_20231122_0223.py new file mode 100644 index 00000000..8251bc87 --- /dev/null +++ b/qgis-app/plugins/migrations/0004_merge_20231122_0223.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.25 on 2023-11-30 05:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0002_plugins_feedback'), + ('plugins', '0003_plugin_allow_update_name'), + ] + + operations = [ + ] diff --git a/qgis-app/plugins/migrations/0005_plugin_maintainer.py b/qgis-app/plugins/migrations/0005_plugin_maintainer.py new file mode 100644 index 00000000..dfeffa0b --- /dev/null +++ b/qgis-app/plugins/migrations/0005_plugin_maintainer.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.25 on 2023-11-29 22:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +def populate_maintainer(apps, schema_editor): + Plugin = apps.get_model('plugins', 'Plugin') + + # Set the maintainer as the plugin creator by default + for obj in Plugin.objects.all(): + obj.maintainer = obj.created_by + obj.save() + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('plugins', '0004_merge_20231122_0223'), + ] + + operations = [ + migrations.AddField( + model_name='plugin', + name='maintainer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='plugins_maintainer', to=settings.AUTH_USER_MODEL, verbose_name='Maintainer'), + ), + migrations.RunPython(populate_maintainer), + ] diff --git a/qgis-app/plugins/migrations/0005_pluginoutstandingtoken.py b/qgis-app/plugins/migrations/0005_pluginoutstandingtoken.py new file mode 100644 index 00000000..4e406433 --- /dev/null +++ b/qgis-app/plugins/migrations/0005_pluginoutstandingtoken.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.25 on 2023-12-11 23:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('token_blacklist', '0007_auto_20171017_2214'), + ('plugins', '0004_merge_20231122_0223'), + ] + + operations = [ + migrations.CreateModel( + name='PluginOutstandingToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_blacklisted', models.BooleanField(default=False)), + ('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plugins.Plugin')), + ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='token_blacklist.OutstandingToken')), + ], + ), + ] diff --git a/qgis-app/plugins/migrations/0006_auto_20231218_0225.py b/qgis-app/plugins/migrations/0006_auto_20231218_0225.py new file mode 100644 index 00000000..60d0af5f --- /dev/null +++ b/qgis-app/plugins/migrations/0006_auto_20231218_0225.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.25 on 2023-12-18 02:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0005_pluginoutstandingtoken'), + ] + + operations = [ + migrations.AddField( + model_name='pluginoutstandingtoken', + name='description', + field=models.CharField(blank=True, help_text="Describe this token so that it's easier to remember where you're using it.", max_length=512, null=True, verbose_name='Description'), + ), + migrations.AddField( + model_name='pluginoutstandingtoken', + name='is_newly_created', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='pluginoutstandingtoken', + name='last_used_on', + field=models.DateTimeField(blank=True, null=True, verbose_name='Last used on'), + ), + ] diff --git a/qgis-app/plugins/migrations/0006_plugin_display_created_by.py b/qgis-app/plugins/migrations/0006_plugin_display_created_by.py new file mode 100644 index 00000000..85af8484 --- /dev/null +++ b/qgis-app/plugins/migrations/0006_plugin_display_created_by.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.25 on 2023-11-29 23:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0005_plugin_maintainer'), + ] + + operations = [ + migrations.AddField( + model_name='plugin', + name='display_created_by', + field=models.BooleanField(default=False, verbose_name='Display "Created by" in plugin details'), + ), + ] diff --git a/qgis-app/plugins/migrations/0007_auto_20240109_0428.py b/qgis-app/plugins/migrations/0007_auto_20240109_0428.py new file mode 100644 index 00000000..6d6af2da --- /dev/null +++ b/qgis-app/plugins/migrations/0007_auto_20240109_0428.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.25 on 2024-01-09 04:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0006_auto_20231218_0225'), + ] + + operations = [ + migrations.AddField( + model_name='pluginversion', + name='is_from_token', + field=models.BooleanField(default=False, verbose_name='Is uploaded using token'), + ), + migrations.AddField( + model_name='pluginversion', + name='token', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='plugins.PluginOutstandingToken', verbose_name='Token used'), + ), + migrations.AlterField( + model_name='pluginversion', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + ] diff --git a/qgis-app/plugins/models.py b/qgis-app/plugins/models.py index eeba37d2..9f9cc209 100644 --- a/qgis-app/plugins/models.py +++ b/qgis-app/plugins/models.py @@ -12,6 +12,7 @@ from django.utils import timezone from djangoratings.fields import AnonymousRatingField from taggit_autosuggest.managers import TaggableManager +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken PLUGINS_STORAGE_PATH = getattr(settings, "PLUGINS_STORAGE_PATH", "packages/%Y") PLUGINS_FRESH_DAYS = getattr(settings, "PLUGINS_FRESH_DAYS", 30) @@ -322,6 +323,22 @@ class Plugin(models.Model): related_name="plugins_created_by", on_delete=models.CASCADE, ) + + # maintainer + maintainer = models.ForeignKey( + User, + verbose_name=_("Maintainer"), + related_name="plugins_maintainer", + on_delete=models.CASCADE, + blank=True, + null=True + ) + + display_created_by = models.BooleanField( + _('Display "Created by" in plugin details'), + default=False + ) + author = models.CharField( _("Author"), help_text=_( @@ -529,6 +546,7 @@ def save(self, keep_date=False, *args, **kwargs): """ Soft triggers: * updates modified_on if keep_date is not set + * set maintainer to the plugin creator when not specified """ if self.pk and not keep_date: import logging @@ -537,6 +555,8 @@ def save(self, keep_date=False, *args, **kwargs): self.modified_on = datetime.datetime.now() if not self.pk: self.modified_on = datetime.datetime.now() + if not self.maintainer: + self.maintainer = self.created_by super(Plugin, self).save(*args, **kwargs) @@ -652,6 +672,33 @@ def from_db_value(self, value, expression, connection): return self.to_python(value) +class PluginOutstandingToken(models.Model): + """ + Plugin outstanding token + """ + plugin = models.ForeignKey( + Plugin, + on_delete=models.CASCADE + ) + token = models.ForeignKey( + OutstandingToken, + on_delete=models.CASCADE + ) + is_blacklisted = models.BooleanField(default=False) + is_newly_created = models.BooleanField(default=False) + description = models.CharField( + verbose_name=_("Description"), + help_text=_("Describe this token so that it's easier to remember where you're using it."), + max_length=512, + blank=True, + null=True, + ) + last_used_on = models.DateTimeField( + verbose_name=_("Last used on"), + blank=True, + null=True + ) + class PluginVersion(models.Model): """ Plugin versions @@ -667,7 +714,7 @@ class PluginVersion(models.Model): downloads = models.IntegerField(_("Downloads"), default=0, editable=False) # owners created_by = models.ForeignKey( - User, verbose_name=_("Created by"), on_delete=models.CASCADE + User, verbose_name=_("Created by"), on_delete=models.CASCADE, null=True, blank=True ) # version info, the first should be read from plugin min_qg_version = QGVersionZeroForcedField( @@ -703,6 +750,14 @@ class PluginVersion(models.Model): blank=False, null=True, ) + is_from_token = models.BooleanField( + _("Is uploaded using token"), + default=False + ) + # Link to the token if upload is using token + token = models.ForeignKey( + PluginOutstandingToken, verbose_name=_("Token used"), on_delete=models.CASCADE, null=True, blank=True + ) # Managers, used in xml output objects = models.Manager() diff --git a/qgis-app/plugins/templates/plugins/plugin_detail.html b/qgis-app/plugins/templates/plugins/plugin_detail.html index 951862d0..82ac70c9 100644 --- a/qgis-app/plugins/templates/plugins/plugin_detail.html +++ b/qgis-app/plugins/templates/plugins/plugin_detail.html @@ -1,5 +1,6 @@ {% extends 'plugins/plugin_base.html' %}{% load i18n static thumbnail %} {% load local_timezone %} +{% load plugin_utils %} {% block extrajs %} {{ block.super }} @@ -38,11 +39,97 @@ }); }); }); + + // Handle URL anchor for tabs + $(window).load(function() { + + // Store the current scroll position + var scrollPosition = 0; + + // Handle tab clicks + $('.nav-tabs a').on('click', function (e) { + e.preventDefault(); + + // Store the current scroll position + scrollPosition = $(window).scrollTop(); + + // Update the URL without triggering a reload + window.location.hash = this.hash; + + // Show the tab + $(this).tab('show'); + }); + + // Restore the scroll position on tab change + $('.nav-tabs a').on('shown.bs.tab', function (e) { + $(window).scrollTop(scrollPosition); + }); + + // Activate the tab based on the URL fragment + var hash = window.location.hash; + if (hash) { + $('.nav-tabs a[href="' + hash + '"]').tab('show'); + } + + // Scroll to the top when the page loads + setTimeout(() => { + $(window).scrollTop(0); + }) + + }); + + function copyToClipBoard(plugin_id) { + navigator.clipboard.writeText(plugin_id); + + var tooltip = document.getElementById("copyTooltip"); + tooltip.innerHTML = "Plugin ID copied!"; + } {% endblock %} {% block extracss %} {{ block.super }} + {% endblock %} {% block content %}