diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..5b486340f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: Python CI + +on: + push: + branches: [master] + pull_request: + branches: + - '**' + +jobs: + run_tests: + name: Tests + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04] + python-version: ['3.8'] + toxenv: [django22, django30, django31] + steps: + - uses: actions/checkout@v1 + - name: setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install pip + run: pip install -r requirements/pip.txt + + - name: Install Dependencies + run: pip install -r requirements/ci.txt + + - name: Run Tests + env: + TOXENV: ${{ matrix.toxenv }} + run: tox diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 000000000..e2b066153 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,10 @@ +# Run commitlint on the commit messages in a pull request. + +name: Lint Commit Messages + +on: + - pull_request + +jobs: + commitlint: + uses: edx/.github/.github/workflows/commitlint.yml@master diff --git a/.github/workflows/upgrade-python-requirements.yml b/.github/workflows/upgrade-python-requirements.yml new file mode 100644 index 000000000..1c485ab5b --- /dev/null +++ b/.github/workflows/upgrade-python-requirements.yml @@ -0,0 +1,68 @@ +name: Upgrade Requirements + +on: + schedule: + # will start the job at 2 every Tuesday (UTC) + - cron: "0 2 * * 2" + workflow_dispatch: + inputs: + branch: + description: "Target branch to create requirements PR against" + required: true + default: 'edx_release' + +jobs: + upgrade_requirements: + runs-on: ubuntu-20.04 + + strategy: + matrix: + python-version: ["3.8"] + + steps: + - name: setup target branch + run: echo "target_branch=$(if ['${{ github.event.inputs.branch }}' = '']; then echo 'edx_release'; else echo '${{ github.event.inputs.branch }}'; fi)" >> $GITHUB_ENV + + - uses: actions/checkout@v1 + with: + ref: ${{ env.target_branch }} + + - name: setup python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: make upgrade + run: | + cd $GITHUB_WORKSPACE + make upgrade + + - name: setup testeng-ci + run: | + git clone https://github.com/edx/testeng-ci.git + cd $GITHUB_WORKSPACE/testeng-ci + pip install -r requirements/base.txt + - name: create pull request + env: + GITHUB_TOKEN: ${{ secrets.REQUIREMENTS_BOT_GITHUB_TOKEN }} + GITHUB_USER_EMAIL: ${{ secrets.REQUIREMENTS_BOT_GITHUB_EMAIL }} + run: | # replace user-reviewers and team-reviewers accordingly + cd $GITHUB_WORKSPACE/testeng-ci + python -m jenkins.pull_request_creator --repo-root=$GITHUB_WORKSPACE \ + --target-branch="${{ env.target_branch }}" --base-branch-name="upgrade-python-requirements" \ + --commit-message="chore: Updating Python Requirements" --pr-title="Python Requirements Update" \ + --pr-body="Python requirements update.Please review the [changelogs](https://openedx.atlassian.net/wiki/spaces/TE/pages/1001521320/Python+Package+Changelogs) for the upgraded packages." \ + --user-reviewers="" --team-reviewers="arbi-bom" --delete-old-pull-requests + + - name: Send failure notification + if: ${{ failure() }} + uses: dawidd6/action-send-mail@v3 + with: + server_address: email-smtp.us-east-1.amazonaws.com + server_port: 465 + username: ${{secrets.EDX_SMTP_USERNAME}} + password: ${{secrets.EDX_SMTP_PASSWORD}} + subject: Upgrade python requirements workflow failed in ${{github.repository}} + to: arbi-bom@edx.org # replace the email with team's email address + from: github-actions + body: Upgrade python requirements workflow in ${{github.repository}} failed! For details see "github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.gitignore b/.gitignore index 73fb529d2..36d459f8b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ virtualenv # text editors *~ *.swp + +.idea/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..c04bbe4bf --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: python +python: + - 3.8 +install: + - pip install -r requirements/ci.txt +env: + - TOXENV=py38-django22 + - TOXENV=py38-django30 + - TOXENV=py38-django31 +script: + - tox diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..38b8aab96 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include CHANGELOG.rst +include CONTRIBUTING.rst +include LICENSE.txt +include README.rst +recursive-include wiki *.html *.png *.gif *js *.css *jpg *jpeg *svg *py *.txt *.json +recursive-include django_notify *.html *.png *.gif *js *.css *jpg *jpeg *svg *py *.txt *.json +include requirements/base.in + +include requirements/constraints.txt diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..aac2eaff4 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +upgrade: export CUSTOM_COMPILE_COMMAND=make upgrade +upgrade: + pip install -q -r requirements/pip_tools.txt + pip-compile --upgrade --allow-unsafe --rebuild -o requirements/pip.txt requirements/pip.in + pip-compile --upgrade -o requirements/pip_tools.txt requirements/pip_tools.in + pip-compile --upgrade -o requirements/base.txt requirements/base.in + pip-compile --upgrade -o requirements/docs.txt requirements/docs.in + pip-compile --upgrade -o requirements/test.txt requirements/test.in + pip-compile --upgrade -o requirements/tox.txt requirements/tox.in + pip-compile --upgrade -o requirements/ci.txt requirements/ci.in + # Let tox control the Django version for tests + grep -e "^django==" requirements/base.txt > requirements/django.txt + sed '/^[dD]jango==/d' requirements/test.txt > requirements/test.tmp + mv requirements/test.tmp requirements/test.txt diff --git a/django_notify/__init__.py b/django_notify/__init__.py index a5966f1b5..e69de29bb 100644 --- a/django_notify/__init__.py +++ b/django_notify/__init__.py @@ -1,40 +0,0 @@ -from django.contrib.contenttypes.models import ContentType -from django.db.models import Model -from django.utils.translation import ugettext as _ - -import models - -_disable_notifications = False - -def notify(message, key, target_object=None, url=None): - """ - Notify subscribing users of a new event. Key can be any kind of string, - just make sure to reuse it where applicable! Object_id is some identifier - of an object, for instance if a user subscribes to a specific comment thread, - you could write: - - notify("there was a response to your comment", "comment_response", - target_object=PostersObject, - url=reverse('comments:view', args=(PostersObject.id,))) - - The below example notifies everyone subscribing to the "new_comments" key - with the message "New comment posted". - - notify("New comment posted", "new_comments") - - """ - - if _disable_notifications: - return 0 - - if target_object: - if not isinstance(target_object, Model): - raise TypeError(_(u"You supplied a target_object that's not an instance of a django Model.")) - object_id = target_object.id - else: - object_id = None - - objects = models.Notification.create_notifications(key, object_id=object_id, - message=message, url=url) - return len(objects) - \ No newline at end of file diff --git a/django_notify/admin.py b/django_notify/admin.py index 957946de8..1abc3a9d3 100644 --- a/django_notify/admin.py +++ b/django_notify/admin.py @@ -5,4 +5,4 @@ admin.site.register(models.NotificationType) admin.site.register(models.Notification) admin.site.register(models.Settings) -admin.site.register(models.Subscription) \ No newline at end of file +admin.site.register(models.Subscription) diff --git a/django_notify/decorators.py b/django_notify/decorators.py index f1d5771d2..518881ace 100644 --- a/django_notify/decorators.py +++ b/django_notify/decorators.py @@ -1,10 +1,11 @@ -# -*- coding: utf-8 -*- -from django.utils import simplejson as json -from django.http import HttpResponse +import json + from django.contrib.auth.decorators import login_required +from django.http import HttpResponse import django_notify + def disable_notify(func): """Disable notifications. Example: @@ -25,7 +26,7 @@ def login_required_ajax(func): def wrap(request, *args, **kwargs): if request.is_ajax(): - if not request.user or not request.user.is_authenticated(): + if not request.user or not request.user.is_authenticated: return json_view(lambda *a, **kw: {'error': 'not logged in'})(request, status=403) return func(request, *args, **kwargs) else: @@ -37,8 +38,7 @@ def wrap(request, *args, **kwargs): obj = func(request, *args, **kwargs) data = json.dumps(obj, ensure_ascii=False) status = kwargs.get('status', 200) - response = HttpResponse(mimetype='application/json', status=status) + response = HttpResponse(content_type='application/json', status=status) response.write(data) return response return wrap - diff --git a/django_notify/migrations/0001_initial.py b/django_notify/migrations/0001_initial.py index 55645602b..4a77c0d96 100644 --- a/django_notify/migrations/0001_initial.py +++ b/django_notify/migrations/0001_initial.py @@ -1,133 +1,81 @@ -# -*- coding: utf-8 -*- -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models -class Migration(SchemaMigration): +class Migration(migrations.Migration): - def forwards(self, orm): - # Adding model 'NotificationType' - db.create_table('notify_notificationtype', ( - ('key', self.gf('django.db.models.fields.CharField')(unique=True, max_length=128, primary_key=True)), - ('label', self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True)), - ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['contenttypes.ContentType'], null=True, blank=True)), - )) - db.send_create_signal('django_notify', ['NotificationType']) + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0001_initial'), + ] - # Adding model 'Settings' - db.create_table('notify_settings', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), - ('interval', self.gf('django.db.models.fields.SmallIntegerField')(default=0)), - )) - db.send_create_signal('django_notify', ['Settings']) - - # Adding model 'Subscription' - db.create_table('notify_subscription', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('settings', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['django_notify.Settings'])), - ('notification_type', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['django_notify.NotificationType'])), - ('object_id', self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True)), - ('send_emails', self.gf('django.db.models.fields.BooleanField')(default=True)), - )) - db.send_create_signal('django_notify', ['Subscription']) - - # Adding model 'Notification' - db.create_table('notify_notification', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('subscription', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['django_notify.Subscription'], null=True, on_delete=models.SET_NULL, blank=True)), - ('message', self.gf('django.db.models.fields.TextField')()), - ('url', self.gf('django.db.models.fields.URLField')(max_length=200, null=True, blank=True)), - ('is_viewed', self.gf('django.db.models.fields.BooleanField')(default=False)), - ('is_emailed', self.gf('django.db.models.fields.BooleanField')(default=False)), - ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), - )) - db.send_create_signal('django_notify', ['Notification']) - - - def backwards(self, orm): - # Deleting model 'NotificationType' - db.delete_table('notify_notificationtype') - - # Deleting model 'Settings' - db.delete_table('notify_settings') - - # Deleting model 'Subscription' - db.delete_table('notify_subscription') - - # Deleting model 'Notification' - db.delete_table('notify_notification') - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'django_notify.notification': { - 'Meta': {'object_name': 'Notification', 'db_table': "'notify_notification'"}, - 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_emailed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_viewed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'message': ('django.db.models.fields.TextField', [], {}), - 'subscription': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['django_notify.Subscription']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}), - 'url': ('django.db.models.fields.URLField', [], {'max_length': '200', 'null': 'True', 'blank': 'True'}) - }, - 'django_notify.notificationtype': { - 'Meta': {'object_name': 'NotificationType', 'db_table': "'notify_notificationtype'"}, - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']", 'null': 'True', 'blank': 'True'}), - 'key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128', 'primary_key': 'True'}), - 'label': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}) - }, - 'django_notify.settings': { - 'Meta': {'object_name': 'Settings', 'db_table': "'notify_settings'"}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'interval': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'django_notify.subscription': { - 'Meta': {'object_name': 'Subscription', 'db_table': "'notify_subscription'"}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'notification_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['django_notify.NotificationType']"}), - 'object_id': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'send_emails': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'settings': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['django_notify.Settings']"}) - } - } - - complete_apps = ['django_notify'] \ No newline at end of file + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('message', models.TextField()), + ('url', models.URLField(null=True, verbose_name='link for notification', blank=True)), + ('is_viewed', models.BooleanField(default=False)), + ('is_emailed', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'notify_notification', + 'verbose_name': 'notification', + 'verbose_name_plural': 'notifications', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='NotificationType', + fields=[ + ('key', models.CharField(max_length=128, unique=True, serialize=False, verbose_name='unique key', primary_key=True)), + ('label', models.CharField(max_length=128, null=True, verbose_name='verbose name', blank=True)), + ('content_type', models.ForeignKey(blank=True, to='contenttypes.ContentType', null=True, on_delete=models.CASCADE)), + ], + options={ + 'db_table': 'notify_notificationtype', + 'verbose_name': 'type', + 'verbose_name_plural': 'types', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Settings', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('interval', models.SmallIntegerField(default=0, verbose_name='interval', choices=[(0, 'instantly'), (23, 'daily'), (167, 'weekly')])), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ], + options={ + 'db_table': 'notify_settings', + 'verbose_name': 'settings', + 'verbose_name_plural': 'settings', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('subscription_id', models.AutoField(serialize=False, primary_key=True)), + ('object_id', models.CharField(help_text='Leave this blank to subscribe to any kind of object', max_length=64, null=True, blank=True)), + ('send_emails', models.BooleanField(default=True)), + ('notification_type', models.ForeignKey(to='django_notify.NotificationType', on_delete=models.CASCADE)), + ('settings', models.ForeignKey(to='django_notify.Settings', on_delete=models.CASCADE)), + ], + options={ + 'db_table': 'notify_subscription', + 'verbose_name': 'subscription', + 'verbose_name_plural': 'subscriptions', + }, + bases=(models.Model,), + ), + migrations.AddField( + model_name='notification', + name='subscription', + field=models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to='django_notify.Subscription', null=True), + preserve_default=True, + ), + ] diff --git a/django_notify/models.py b/django_notify/models.py index db80a26c8..0687bb3c1 100644 --- a/django_notify/models.py +++ b/django_notify/models.py @@ -1,66 +1,70 @@ -# -*- coding: utf-8 -*- -from django.db import models -from django.db.models import Q from django.contrib.auth.models import User -from django.utils.translation import ugettext_lazy as _ - from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.db.models import Model, Q +from django.utils.translation import ugettext_lazy as _ from django_notify import settings +_disable_notifications = False + class NotificationType(models.Model): """ Notification types are added on-the-fly by the applications adding new notifications""" - key = models.CharField(max_length=128, primary_key=True, verbose_name=_(u'unique key'), + key = models.CharField(max_length=128, primary_key=True, verbose_name=_('unique key'), unique=True) - label = models.CharField(max_length=128, verbose_name=_(u'verbose name'), + label = models.CharField(max_length=128, verbose_name=_('verbose name'), blank=True, null=True) - content_type = models.ForeignKey(ContentType, blank=True, null=True) + content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE) def __unicode__(self): return self.key class Meta: + app_label = 'django_notify' db_table = settings.DB_TABLE_PREFIX + '_notificationtype' - verbose_name = _(u'type') - verbose_name_plural = _(u'types') + verbose_name = _('type') + verbose_name_plural = _('types') class Settings(models.Model): - user = models.ForeignKey(User) - interval = models.SmallIntegerField(choices=settings.INTERVALS, verbose_name=_(u'interval'), + user = models.ForeignKey(User, on_delete=models.CASCADE) + interval = models.SmallIntegerField(choices=settings.INTERVALS, verbose_name=_('interval'), default=settings.INTERVALS_DEFAULT) def __unicode__(self): - return _(u"Settings for %s") % self.user.username + return _("Settings for %s") % self.user.username class Meta: + app_label = 'django_notify' db_table = settings.DB_TABLE_PREFIX + '_settings' - verbose_name = _(u'settings') - verbose_name_plural = _(u'settings') + verbose_name = _('settings') + verbose_name_plural = _('settings') class Subscription(models.Model): - settings = models.ForeignKey(Settings) - notification_type = models.ForeignKey(NotificationType) + subscription_id = models.AutoField(primary_key=True) + settings = models.ForeignKey(Settings, on_delete=models.CASCADE) + notification_type = models.ForeignKey(NotificationType, on_delete=models.CASCADE) object_id = models.CharField(max_length=64, null=True, blank=True, - help_text=_(u'Leave this blank to subscribe to any kind of object')) + help_text=_('Leave this blank to subscribe to any kind of object')) send_emails = models.BooleanField(default=True) def __unicode__(self): return _("Subscription for: %s") % str(self.settings.user.username) class Meta: + app_label = 'django_notify' db_table = settings.DB_TABLE_PREFIX + '_subscription' - verbose_name = _(u'subscription') - verbose_name_plural = _(u'subscriptions') + verbose_name = _('subscription') + verbose_name_plural = _('subscriptions') class Notification(models.Model): subscription = models.ForeignKey(Subscription, null=True, blank=True, on_delete=models.SET_NULL) message = models.TextField() - url = models.URLField(blank=True, null=True, verbose_name=_(u'link for notification')) + url = models.URLField(blank=True, null=True, verbose_name=_('link for notification')) is_viewed = models.BooleanField(default=False) is_emailed = models.BooleanField(default=False) created = models.DateTimeField(auto_now_add=True) @@ -97,6 +101,40 @@ def __unicode__(self): return "%s: %s" % (str(self.subscription.settings.user), self.message) class Meta: + app_label = 'django_notify' db_table = settings.DB_TABLE_PREFIX + '_notification' - verbose_name = _(u'notification') - verbose_name_plural = _(u'notifications') + verbose_name = _('notification') + verbose_name_plural = _('notifications') + + +def notify(message, key, target_object=None, url=None): + """ + Notify subscribing users of a new event. Key can be any kind of string, + just make sure to reuse it where applicable! Object_id is some identifier + of an object, for instance if a user subscribes to a specific comment thread, + you could write: + + notify("there was a response to your comment", "comment_response", + target_object=PostersObject, + url=reverse('comments:view', args=(PostersObject.id,))) + + The below example notifies everyone subscribing to the "new_comments" key + with the message "New comment posted". + + notify("New comment posted", "new_comments") + + """ + + if _disable_notifications: + return 0 + + if target_object: + if not isinstance(target_object, Model): + raise TypeError(_("You supplied a target_object that's not an instance of a django Model.")) + object_id = target_object.id + else: + object_id = None + + objects = Notification.create_notifications(key, object_id=object_id, + message=message, url=url) + return len(objects) diff --git a/django_notify/settings.py b/django_notify/settings.py index a2a1c3859..334d42341 100644 --- a/django_notify/settings.py +++ b/django_notify/settings.py @@ -20,9 +20,9 @@ WEEKLY = 7*24-1 INTERVALS = getattr(django_settings, "NOTIFY_INTERVALS", - [(INSTANTLY, _(u'instantly')), - (DAILY, _(u'daily')), - (WEEKLY, _(u'weekly'))]) + [(INSTANTLY, _('instantly')), + (DAILY, _('daily')), + (WEEKLY, _('weekly'))]) INTERVALS_DEFAULT = INSTANTLY diff --git a/django_notify/tests.py b/django_notify/tests.py index 501deb776..06eeec547 100644 --- a/django_notify/tests.py +++ b/django_notify/tests.py @@ -5,6 +5,7 @@ Replace this with more appropriate tests for your application. """ + from django.test import TestCase diff --git a/django_notify/urls.py b/django_notify/urls.py index b5270f3b1..90c318303 100644 --- a/django_notify/urls.py +++ b/django_notify/urls.py @@ -1,16 +1,19 @@ -# -*- coding: utf-8 -*- -from django.conf.urls.defaults import patterns, url +from django.conf.urls import url -urlpatterns = patterns('', - url('^json/get/$', 'django_notify.views.get_notifications', name='json_get', kwargs={}), - url('^json/mark-read/$', 'django_notify.views.mark_read', name='json_mark_read_base', kwargs={}), - url('^json/mark-read/(\d+)/$', 'django_notify.views.mark_read', name='json_mark_read', kwargs={}), - url('^goto/(?P\d+)/$', 'django_notify.views.goto', name='goto', kwargs={}), - url('^goto/$', 'django_notify.views.goto', name='goto_base', kwargs={}), -) +from django_notify import views -def get_pattern(app_name="notify", namespace="notify"): +urlpatterns = [ + url('^json/get/$', views.get_notifications, name='json_get', kwargs={}), + url('^json/mark-read/$', views.mark_read, name='json_mark_read_base', kwargs={}), + url(r'^json/mark-read/(\d+)/$', views.mark_read, name='json_mark_read', kwargs={}), + url(r'^goto/(?P\d+)/$', views.goto, name='goto', kwargs={}), + url('^goto/$', views.goto, name='goto_base', kwargs={}), +] + +app_name = 'notify' + +def get_pattern(app_name="notify"): """Every url resolution takes place as "notify:view_name". https://docs.djangoproject.com/en/dev/topics/http/urls/#topics-http-reversing-url-namespaces """ - return urlpatterns, app_name, namespace \ No newline at end of file + return urlpatterns, app_name \ No newline at end of file diff --git a/django_notify/views.py b/django_notify/views.py index 9b7489f29..99fda5d8a 100644 --- a/django_notify/views.py +++ b/django_notify/views.py @@ -1,9 +1,9 @@ -# -*- coding: utf-8 -*- +from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404, redirect -from django_notify.decorators import json_view, login_required_ajax from django_notify import models -from django.contrib.auth.decorators import login_required -from django.shortcuts import redirect, get_object_or_404 +from django_notify.decorators import json_view, login_required_ajax + @login_required_ajax @json_view @@ -55,4 +55,4 @@ def mark_read(request, up_to_id, notification_type_id=None): notifications.update(is_viewed=True) - return {'success': True} \ No newline at end of file + return {'success': True} diff --git a/docs/conf.py b/docs/conf.py index fb261143d..4e5504f41 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # django-wiki documentation build configuration file, created by # sphinx-quickstart on Mon Jul 23 16:13:51 2012. @@ -11,7 +10,9 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os + +import os +import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -40,8 +41,8 @@ master_doc = 'index' # General information about the project. -project = u'django-wiki' -copyright = u'2012, Benjamin Bach' +project = 'django-wiki' +copyright = '2012, Benjamin Bach' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -183,8 +184,8 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'django-wiki.tex', u'django-wiki Documentation', - u'Benjamin Bach', 'manual'), + ('index', 'django-wiki.tex', 'django-wiki Documentation', + 'Benjamin Bach', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -213,8 +214,8 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'django-wiki', u'django-wiki Documentation', - [u'Benjamin Bach'], 1) + ('index', 'django-wiki', 'django-wiki Documentation', + ['Benjamin Bach'], 1) ] # If true, show URL addresses after external links. @@ -227,8 +228,8 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'django-wiki', u'django-wiki Documentation', - u'Benjamin Bach', 'django-wiki', 'One line description of project.', + ('index', 'django-wiki', 'django-wiki Documentation', + 'Benjamin Bach', 'django-wiki', 'One line description of project.', 'Miscellaneous'), ] diff --git a/openedx.yaml b/openedx.yaml new file mode 100644 index 000000000..097eb65fd --- /dev/null +++ b/openedx.yaml @@ -0,0 +1,10 @@ +# This file describes this Open edX repo, as described in OEP-2: +# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification: + +nick: django-wiki +tags: + - core + - library +oeps: + oep-7: true + oep-18: true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e59f547d7..000000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -django>=1.4 -South<0.8 -Markdown<2.3.0 -django-sekizai<0.7 -django-mptt>=0.5.3 -sorl-thumbnail \ No newline at end of file diff --git a/requirements/base.in b/requirements/base.in new file mode 100644 index 000000000..8feb9227e --- /dev/null +++ b/requirements/base.in @@ -0,0 +1,9 @@ +# Core requirements for using this package +-c constraints.txt + +bleach # Use for sanitizing HTML content in wiki articles +django +django-mptt +django-sekizai +Markdown +sorl-thumbnail # Required by wiki.plugins.images diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 000000000..f5dee6b68 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,38 @@ +# +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: +# +# make upgrade +# +bleach==4.1.0 + # via -r requirements/base.in +django==2.2.24 + # via + # -c requirements/constraints.txt + # -r requirements/base.in + # django-classy-tags + # django-sekizai +django-classy-tags==2.0.0 + # via django-sekizai +django-js-asset==1.2.2 + # via django-mptt +django-mptt==0.13.4 + # via -r requirements/base.in +django-sekizai==2.0.0 + # via -r requirements/base.in +markdown==3.3.4 + # via -r requirements/base.in +packaging==21.2 + # via bleach +pyparsing==2.4.7 + # via packaging +pytz==2021.3 + # via django +six==1.16.0 + # via bleach +sorl-thumbnail==12.7.0 + # via -r requirements/base.in +sqlparse==0.4.2 + # via django +webencodings==0.5.1 + # via bleach diff --git a/requirements/ci.in b/requirements/ci.in new file mode 100644 index 000000000..3dbd7b0c3 --- /dev/null +++ b/requirements/ci.in @@ -0,0 +1,4 @@ +# Requirements for running tests in Travis +-c constraints.txt + +-r tox.txt diff --git a/requirements/ci.txt b/requirements/ci.txt new file mode 100644 index 000000000..2f39271a7 --- /dev/null +++ b/requirements/ci.txt @@ -0,0 +1,54 @@ +# +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: +# +# make upgrade +# +backports.entry-points-selectable==1.1.0 + # via + # -r requirements/tox.txt + # virtualenv +distlib==0.3.3 + # via + # -r requirements/tox.txt + # virtualenv +filelock==3.3.2 + # via + # -r requirements/tox.txt + # tox + # virtualenv +packaging==21.2 + # via + # -r requirements/tox.txt + # tox +platformdirs==2.4.0 + # via + # -r requirements/tox.txt + # virtualenv +pluggy==1.0.0 + # via + # -r requirements/tox.txt + # tox +py==1.11.0 + # via + # -r requirements/tox.txt + # tox +pyparsing==2.4.7 + # via + # -r requirements/tox.txt + # packaging +six==1.16.0 + # via + # -r requirements/tox.txt + # tox + # virtualenv +toml==0.10.2 + # via + # -r requirements/tox.txt + # tox +tox==3.24.4 + # via -r requirements/tox.txt +virtualenv==20.10.0 + # via + # -r requirements/tox.txt + # tox diff --git a/requirements/constraints.txt b/requirements/constraints.txt new file mode 100644 index 000000000..8a2cc2b0a --- /dev/null +++ b/requirements/constraints.txt @@ -0,0 +1,14 @@ +# Version constraints for pip-installation. +# +# This file doesn't install any packages. It specifies version constraints +# that will be applied if a package is needed. +# +# When pinning something here, please provide an explanation of why. Ideally, +# link to other information that will help people in the future to remove the +# pin when possible. Writing an issue against the offending project and +# linking to it here is good. + +# TODO: Many pinned dependencies should be unpinned and/or moved to this constraints file. + +# Use latest Django LTS version +Django<2.3.0 diff --git a/requirements/django.txt b/requirements/django.txt new file mode 100644 index 000000000..f44fd3316 --- /dev/null +++ b/requirements/django.txt @@ -0,0 +1 @@ +django==2.2.24 diff --git a/requirements/docs.in b/requirements/docs.in new file mode 100644 index 000000000..832d305e3 --- /dev/null +++ b/requirements/docs.in @@ -0,0 +1,5 @@ +# Used by doc builds (like for edx.readthedocs.io) +-c constraints.txt + +edx-sphinx-theme +Sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 000000000..2cb844407 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,61 @@ +# +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: +# +# make upgrade +# +alabaster==0.7.12 + # via sphinx +babel==2.9.1 + # via sphinx +certifi==2021.10.8 + # via requests +charset-normalizer==2.0.7 + # via requests +docutils==0.17.1 + # via sphinx +edx-sphinx-theme==3.0.0 + # via -r requirements/docs.in +idna==3.3 + # via requests +imagesize==1.2.0 + # via sphinx +jinja2==3.0.2 + # via sphinx +markupsafe==2.0.1 + # via jinja2 +packaging==21.2 + # via sphinx +pygments==2.10.0 + # via sphinx +pyparsing==2.4.7 + # via packaging +pytz==2021.3 + # via babel +requests==2.26.0 + # via sphinx +six==1.16.0 + # via edx-sphinx-theme +snowballstemmer==2.1.0 + # via sphinx +sphinx==4.2.0 + # via + # -r requirements/docs.in + # edx-sphinx-theme +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +urllib3==1.26.7 + # via requests + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/pip.in b/requirements/pip.in new file mode 100644 index 000000000..7015e2e2f --- /dev/null +++ b/requirements/pip.in @@ -0,0 +1,3 @@ +pip +setuptools +wheel diff --git a/requirements/pip.txt b/requirements/pip.txt new file mode 100644 index 000000000..ac73ce516 --- /dev/null +++ b/requirements/pip.txt @@ -0,0 +1,14 @@ +# +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: +# +# make upgrade +# +wheel==0.37.0 + # via -r requirements/pip.in + +# The following packages are considered to be unsafe in a requirements file: +pip==21.3.1 + # via -r requirements/pip.in +setuptools==58.5.3 + # via -r requirements/pip.in diff --git a/requirements/pip_tools.in b/requirements/pip_tools.in new file mode 100644 index 000000000..b10701838 --- /dev/null +++ b/requirements/pip_tools.in @@ -0,0 +1,3 @@ +# Dependencies to run compile tools +-c constraints.txt +pip-tools # Contains pip-compile, used to generate pip requirements files diff --git a/requirements/pip_tools.txt b/requirements/pip_tools.txt new file mode 100644 index 000000000..2aea4ec88 --- /dev/null +++ b/requirements/pip_tools.txt @@ -0,0 +1,20 @@ +# +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: +# +# make upgrade +# +click==8.0.3 + # via pip-tools +pep517==0.12.0 + # via pip-tools +pip-tools==6.4.0 + # via -r requirements/pip_tools.in +tomli==1.2.2 + # via pep517 +wheel==0.37.0 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/test.in b/requirements/test.in new file mode 100644 index 000000000..8154ccb2c --- /dev/null +++ b/requirements/test.in @@ -0,0 +1,8 @@ +# Packages required for testing +-c constraints.txt + +-r base.txt + +pytest +pytest-cov +pytest-django diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 000000000..4db817306 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,77 @@ +# +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: +# +# make upgrade +# +attrs==21.2.0 + # via pytest +bleach==4.1.0 + # via -r requirements/base.txt +coverage[toml]==6.1.1 + # via pytest-cov + # via + # -c requirements/constraints.txt + # -r requirements/base.txt + # django-classy-tags + # django-sekizai +django-classy-tags==2.0.0 + # via + # -r requirements/base.txt + # django-sekizai +django-js-asset==1.2.2 + # via + # -r requirements/base.txt + # django-mptt +django-mptt==0.13.4 + # via -r requirements/base.txt +django-sekizai==2.0.0 + # via -r requirements/base.txt +iniconfig==1.1.1 + # via pytest +markdown==3.3.4 + # via -r requirements/base.txt +packaging==21.2 + # via + # -r requirements/base.txt + # bleach + # pytest +pluggy==1.0.0 + # via pytest +py==1.11.0 + # via pytest +pyparsing==2.4.7 + # via + # -r requirements/base.txt + # packaging +pytest==6.2.5 + # via + # -r requirements/test.in + # pytest-cov + # pytest-django +pytest-cov==3.0.0 + # via -r requirements/test.in +pytest-django==4.4.0 + # via -r requirements/test.in +pytz==2021.3 + # via + # -r requirements/base.txt + # django +six==1.16.0 + # via + # -r requirements/base.txt + # bleach +sorl-thumbnail==12.7.0 + # via -r requirements/base.txt +sqlparse==0.4.2 + # via + # -r requirements/base.txt + # django +toml==0.10.2 + # via pytest +tomli==1.2.2 + # via coverage +webencodings==0.5.1 + # via + # -r requirements/base.txt + # bleach diff --git a/requirements/tox.in b/requirements/tox.in new file mode 100644 index 000000000..9a2869477 --- /dev/null +++ b/requirements/tox.in @@ -0,0 +1,4 @@ +# Used for tests +-c constraints.txt + +tox diff --git a/requirements/tox.txt b/requirements/tox.txt new file mode 100644 index 000000000..3ed4242de --- /dev/null +++ b/requirements/tox.txt @@ -0,0 +1,34 @@ +# +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: +# +# make upgrade +# +backports.entry-points-selectable==1.1.0 + # via virtualenv +distlib==0.3.3 + # via virtualenv +filelock==3.3.2 + # via + # tox + # virtualenv +packaging==21.2 + # via tox +platformdirs==2.4.0 + # via virtualenv +pluggy==1.0.0 + # via tox +py==1.11.0 + # via tox +pyparsing==2.4.7 + # via packaging +six==1.16.0 + # via + # tox + # virtualenv +toml==0.10.2 + # via tox +tox==3.24.4 + # via -r requirements/tox.in +virtualenv==20.10.0 + # via tox diff --git a/setup.py b/setup.py index a3fd67b60..d4cb02c9d 100644 --- a/setup.py +++ b/setup.py @@ -1,50 +1,118 @@ -# -*- coding: utf-8 -*- import os -from setuptools import setup, find_packages +import re + +from setuptools import find_packages, setup + -# Utility function to read the README file. -# Used for the long_description. It's nice, because now 1) we have a top level -# README file and 2) it's easier to type in the README file than to put a raw -# string in below ... def read(fname): + """ + Utility function to read the README file. + Used for the long_description. It's nice, because now 1) we have a top level + README file and 2) it's easier to type in the README file than to put a raw + string in below ... + + """ return open(os.path.join(os.path.dirname(__file__), fname)).read() def build_media_pattern(base_folder, file_extension): return ["%s/%s*.%s" % (base_folder, "*/"*x, file_extension) for x in range(10)] -template_patterns = ( build_media_pattern("templates", "html") + - build_media_pattern("static", "js") + - build_media_pattern("static", "css") + - build_media_pattern("static", "png") + - build_media_pattern("static", "jpeg") + - build_media_pattern("static", "gif")) + +def load_requirements(*requirements_paths): + """ + Load all requirements from the specified requirements files. + + Requirements will include any constraints from files specified + with -c in the requirements files. + Returns a list of requirement strings. + """ + # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why. + + requirements = {} + constraint_files = set() + + # groups "my-package-name<=x.y.z,..." into ("my-package-name", "<=x.y.z,...") + requirement_line_regex = re.compile(r"([a-zA-Z0-9-_.]+)([<>=][^#\s]+)?") + + def add_version_constraint_or_raise(current_line, current_requirements, add_if_not_present): + regex_match = requirement_line_regex.match(current_line) + if regex_match: + package = regex_match.group(1) + version_constraints = regex_match.group(2) + existing_version_constraints = current_requirements.get(package, None) + # it's fine to add constraints to an unconstrained package, but raise an error if there are already + # constraints in place + if existing_version_constraints and existing_version_constraints != version_constraints: + raise BaseException(f'Multiple constraint definitions found for {package}:' + f' "{existing_version_constraints}" and "{version_constraints}".' + f'Combine constraints into one location with {package}' + f'{existing_version_constraints},{version_constraints}.') + if add_if_not_present or package in current_requirements: + current_requirements[package] = version_constraints + + # process .in files and store the path to any constraint files that are pulled in + for path in requirements_paths: + with open(path) as reqs: + for line in reqs: + if is_requirement(line): + add_version_constraint_or_raise(line, requirements, True) + if line and line.startswith('-c') and not line.startswith('-c http'): + constraint_files.add(os.path.dirname(path) + '/' + line.split('#')[0].replace('-c', '').strip()) + + # process constraint files and add any new constraints found to existing requirements + for constraint_file in constraint_files: + with open(constraint_file) as reader: + for line in reader: + if is_requirement(line): + add_version_constraint_or_raise(line, requirements, False) + + # process back into list of pkg><=constraints strings + constrained_requirements = [f'{pkg}{version or ""}' for (pkg, version) in sorted(requirements.items())] + return constrained_requirements + + +def is_requirement(line): + """ + Return True if the requirement line is a package requirement. + + Returns: + bool: True if the line is not blank, a comment, + a URL, or an included file + """ + # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why + + return line and line.strip() and not line.startswith(('-r', '#', '-e', 'git+', '-c')) + + +template_patterns = ( + build_media_pattern("templates", "html") + + build_media_pattern("static", "js") + + build_media_pattern("static", "css") + + build_media_pattern("static", "png") + + build_media_pattern("static", "jpeg") + + build_media_pattern("static", "gif") +) packages = find_packages() -package_data = dict( - (package_name, template_patterns) +package_data = { + package_name: template_patterns for package_name in packages -) +} setup( - name = "django-wiki", - version = "0.0.1", - author = "Benjamin Bach", - author_email = "benjamin@overtag.dk", - description = ("A wiki system written for the Django framework."), - license = "GPLv3", - keywords = "django wiki markdown", - packages=find_packages(exclude=["testproject","testproject.*"]), + name="django-wiki", + version="1.0.2", + author="Benjamin Bach", + author_email="benjamin@overtag.dk", + description="A wiki system written for the Django framework.", + license="GPLv3", + keywords="django wiki markdown", + packages=find_packages(exclude=["testproject", "testproject.*"]), long_description=read('README.md'), - zip_safe = False, - install_requires=[ - 'Django>=1.4', - 'markdown', - 'django-sekizai', - 'south', - 'django-mptt', - ], + zip_safe=False, + install_requires=load_requirements('requirements/base.in'), classifiers=[ 'Development Status :: 2 - Pre-Alpha', 'License :: OSI Approved :: GPLv3', @@ -52,7 +120,12 @@ def build_media_pattern(base_folder, file_extension): 'Framework :: Django', 'Intended Audience :: Developers', 'Operating System :: OS Independent', - 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Framework :: Django', + 'Framework :: Django :: 2.2', + 'Framework :: Django :: 3.0', + 'Framework :: Django :: 3.1', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 'Topic :: Software Development', 'Topic :: Software Development :: Libraries :: Application Frameworks', diff --git a/testproject/manage.py b/testproject/manage.py index 97ed576b6..06c89afba 100755 --- a/testproject/manage.py +++ b/testproject/manage.py @@ -1,4 +1,5 @@ #!/usr/bin/env python + import os import sys diff --git a/testproject/pytest.ini b/testproject/pytest.ini new file mode 100644 index 000000000..810e29de6 --- /dev/null +++ b/testproject/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = testproject.settings +python_files = tests.py test_*.py *_tests.py diff --git a/testproject/testproject/db/prepopulated.db b/testproject/testproject/db/prepopulated.db index d6949712c..b698f291d 100644 Binary files a/testproject/testproject/db/prepopulated.db and b/testproject/testproject/db/prepopulated.db differ diff --git a/testproject/testproject/settings.py b/testproject/testproject/settings.py index 03c6d64ab..d70c498d4 100644 --- a/testproject/testproject/settings.py +++ b/testproject/testproject/settings.py @@ -1,15 +1,14 @@ -# -*- coding: utf-8 -*- from os import path as os_path + PROJECT_PATH = os_path.abspath(os_path.split(__file__)[0]) DEBUG = True -TEMPLATE_DEBUG = DEBUG ADMINS = ( # ('Your Name', 'your_email@example.com'), ) -#from django.core.urlresolvers import reverse_lazy +#from django.urls import reverse_lazy #LOGIN_REDIRECT_URL = reverse_lazy('wiki:get', kwargs={'path': ''}) # This forces the wiki login view to redirect to the referer... @@ -47,28 +46,16 @@ STATIC_ROOT = os_path.join(PROJECT_PATH, 'static') STATIC_URL = '/static/' -STATICFILES_DIRS = ( -) -STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', -# 'django.contrib.staticfiles.finders.DefaultStorageFinder', -) SECRET_KEY = 'b^fv_)t39h%9p40)fnkfblo##jkr!$0)lkp6bpy!fi*f$4*92!' -TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', -# 'django.template.loaders.eggs.Loader', -) - -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', + 'wiki.middleware.RequestCache', # Uncomment the next line for simple clickjacking protection: # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) @@ -78,22 +65,31 @@ # Python dotted path to the WSGI application used by Django's runserver. WSGI_APPLICATION = 'testproject.wsgi.application' -TEMPLATE_DIRS = ( - 'templates', -) - -TEMPLATE_CONTEXT_PROCESSORS =( - 'django.contrib.auth.context_processors.auth', - 'django.core.context_processors.debug', - 'django.core.context_processors.i18n', - 'django.core.context_processors.media', - 'django.core.context_processors.static', - 'django.core.context_processors.tz', - 'django.contrib.messages.context_processors.messages', - 'sekizai.context_processors.sekizai', -) +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os_path.join(PROJECT_PATH, 'templates'), + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.request", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + "sekizai.context_processors.sekizai", + ], + 'debug': DEBUG, + }, + }, +] -INSTALLED_APPS = ( +INSTALLED_APPS = [ 'django.contrib.humanize', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -103,7 +99,6 @@ 'django.contrib.staticfiles', 'django.contrib.admin', 'django.contrib.admindocs', - 'south', 'sekizai', 'django_notify', 'sorl.thumbnail', @@ -114,7 +109,7 @@ 'wiki.plugins.attachments', 'wiki.plugins.notifications', 'mptt', -) +] # A sample logging configuration. The only tangible logging # performed by this configuration is to send an email to @@ -146,3 +141,6 @@ } WIKI_ANONYMOUS_WRITE = True + +# We disable this in edx-platform lms/envs/common.py, so disabling for test project also. +WIKI_USE_BOOTSTRAP_SELECT_WIDGET = False diff --git a/testproject/testproject/urls.py b/testproject/testproject/urls.py index 2bae8b862..eaa47c094 100644 --- a/testproject/testproject/urls.py +++ b/testproject/testproject/urls.py @@ -1,27 +1,26 @@ -from django.conf.urls import patterns, include, url +from django.conf.urls import include, url from django.conf import settings from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.views.static import serve as static_serve from django.contrib import admin admin.autodiscover() -urlpatterns = patterns('', - url(r'^admin/doc/', include('django.contrib.admindocs.urls')), - url(r'^admin/', include(admin.site.urls)), - url(r'^notify/', include('django_notify.urls', namespace='notify')), -) +urlpatterns = [ + url(r'^admin/', admin.site.urls), +] if settings.DEBUG: urlpatterns += staticfiles_urlpatterns() - urlpatterns += patterns('', - url(r'^media/(?P.*)$', 'django.views.static.serve', { - 'document_root': settings.MEDIA_ROOT, - }), - ) - + urlpatterns += [ + url(r'^media/(?P.*)$', static_serve, {'document_root': settings.MEDIA_ROOT}), + ] + + from wiki.urls import get_pattern as get_wiki_pattern from django_notify.urls import get_pattern as get_notify_pattern -urlpatterns += patterns('', - (r'^notify/', get_notify_pattern()), - (r'', get_wiki_pattern()) -) \ No newline at end of file + +urlpatterns += [ + url(r'^notify/', include('django_notify.urls', namespace='notify')), + url(r'', include('wiki.urls', namespace='wiki')), +] diff --git a/testproject/vmanage.py b/testproject/vmanage.py index 0377dbe7b..68fe4826a 100755 --- a/testproject/vmanage.py +++ b/testproject/vmanage.py @@ -1,4 +1,5 @@ #!virtualenv/bin/python + import os import sys diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..f2edeb42a --- /dev/null +++ b/tox.ini @@ -0,0 +1,12 @@ +[tox] +envlist = py38-django{22,30,31} + +[testenv] +deps = + django22: -r requirements/django.txt + django30: Django>=3.0,<3.1 + django31: Django>=3.1,<3.2 + -r{toxinidir}/requirements/test.txt +changedir={toxinidir}/testproject/ +commands = + pytest --cov wiki --cov django_notify diff --git a/wiki/admin.py b/wiki/admin.py index 3d0e86b8b..c8789338c 100644 --- a/wiki/admin.py +++ b/wiki/admin.py @@ -1,34 +1,38 @@ +from django import forms from django.contrib import admin -from django.contrib.contenttypes.generic import GenericTabularInline +from django.contrib.contenttypes.admin import GenericTabularInline from django.utils.translation import ugettext_lazy as _ from mptt.admin import MPTTModelAdmin -from django import forms -import models -import editors +from wiki import editors, models + class ArticleObjectAdmin(GenericTabularInline): model = models.ArticleForObject extra = 1 max_num = 1 + class ArticleRevisionForm(forms.ModelForm): class Meta: model = models.ArticleRevision + fields = '__all__' def __init__(self, *args, **kwargs): - super(ArticleRevisionForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) EditorClass = editors.getEditorClass() editor = editors.getEditor() self.fields['content'].widget = editor.get_admin_widget() + class ArticleRevisionAdmin(admin.ModelAdmin): form = ArticleRevisionForm class Media: js = editors.getEditorClass().AdminMedia.js css = editors.getEditorClass().AdminMedia.css + class ArticleRevisionInline(admin.TabularInline): model = models.ArticleRevision form = ArticleRevisionForm @@ -40,13 +44,15 @@ class Media: js = editors.getEditorClass().AdminMedia.js css = editors.getEditorClass().AdminMedia.css + class ArticleForm(forms.ModelForm): class Meta: model = models.Article + fields = '__all__' def __init__(self, *args, **kwargs): - super(ArticleForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance.pk: revisions = models.ArticleRevision.objects.filter(article=self.instance) self.fields['current_revision'].queryset = revisions @@ -54,10 +60,12 @@ def __init__(self, *args, **kwargs): self.fields['current_revision'].queryset = models.ArticleRevision.objects.get_empty_query_set() self.fields['current_revision'].widget = forms.HiddenInput() + class ArticleAdmin(admin.ModelAdmin): inlines = [ArticleRevisionInline] form = ArticleForm + class URLPathAdmin(MPTTModelAdmin): inlines = [ArticleObjectAdmin] list_filter = ('site', 'articles__article__current_revision__deleted', @@ -67,8 +75,9 @@ class URLPathAdmin(MPTTModelAdmin): def get_created(self, instance): return instance.article.created - get_created.short_description = _(u'created') - + get_created.short_description = _('created') + + admin.site.register(models.URLPath, URLPathAdmin) admin.site.register(models.Article, ArticleAdmin) -admin.site.register(models.ArticleRevision, ArticleRevisionAdmin) \ No newline at end of file +admin.site.register(models.ArticleRevision, ArticleRevisionAdmin) diff --git a/wiki/apps.py b/wiki/apps.py new file mode 100644 index 000000000..42fb43f76 --- /dev/null +++ b/wiki/apps.py @@ -0,0 +1,20 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class NotifcationsConfig(AppConfig): + name = 'wiki.plugins.notifications' + verbose_name = _("Wiki notifications") + label = 'wiki_notifications' + + +class ImagesConfig(AppConfig): + name = 'wiki.plugins.images' + verbose_name = _("Wiki images") + label = 'wiki_images' + + +class AttachmentsConfig(AppConfig): + name = 'wiki.plugins.attachments' + verbose_name = _("Wiki attachments") + label = 'wiki_attachments' diff --git a/wiki/conf/__init__.py b/wiki/conf/__init__.py index 40a96afc6..e69de29bb 100644 --- a/wiki/conf/__init__.py +++ b/wiki/conf/__init__.py @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/wiki/conf/settings.py b/wiki/conf/settings.py index 2f386d2bd..e2cdcfe78 100644 --- a/wiki/conf/settings.py +++ b/wiki/conf/settings.py @@ -1,12 +1,11 @@ -# -*- coding: utf-8 -*- +import bleach from django.conf import settings as django_settings -from django.core.urlresolvers import reverse_lazy +from django.urls import reverse_lazy # Should urls be case sensitive? URL_CASE_SENSITIVE = getattr(django_settings, 'WIKI_URL_CASE_SENSITIVE', False) # Non-configurable (at the moment) -APP_LABEL = 'wiki' WIKI_LANGUAGE = 'markdown' # The editor class to use -- maybe a 3rd party or your own...? You can always @@ -15,6 +14,77 @@ MARKDOWN_EXTENSIONS = getattr(django_settings, 'WIKI_MARKDOWN_EXTENSIONS', ['extra', 'toc']) +######################## +## HTML sanitization code copied from the upstream repo +######################## + +#: Whether to use Bleach or not. It's not recommended to turn this off unless +#: you know what you're doing and you don't want to use the other options. +MARKDOWN_SANITIZE_HTML = getattr(django_settings, "WIKI_MARKDOWN_SANITIZE_HTML", True) + +_default_tag_whitelists = ( + bleach.ALLOWED_TAGS + + [ + "figure", + "figcaption", + "br", + "hr", + "p", + "div", + "img", + "pre", + "span", + "sup", + "table", + "thead", + "tbody", + "th", + "tr", + "td", + "dl", + "dt", + "dd", + ] + + ["h{}".format(n) for n in range(1, 7)] +) + + +#: List of allowed tags in Markdown article contents. +MARKDOWN_HTML_WHITELIST = _default_tag_whitelists +MARKDOWN_HTML_WHITELIST += getattr(django_settings, "WIKI_MARKDOWN_HTML_WHITELIST", []) + +_default_attribute_whitelist = bleach.ALLOWED_ATTRIBUTES +for tag in MARKDOWN_HTML_WHITELIST: + if tag not in _default_attribute_whitelist: + _default_attribute_whitelist[tag] = [] + _default_attribute_whitelist[tag].append("class") + _default_attribute_whitelist[tag].append("id") + _default_attribute_whitelist[tag].append("target") + _default_attribute_whitelist[tag].append("rel") + +_default_attribute_whitelist["img"].append("src") +_default_attribute_whitelist["img"].append("alt") + +#: Dictionary of allowed attributes in Markdown article contents. +MARKDOWN_HTML_ATTRIBUTES = _default_attribute_whitelist +MARKDOWN_HTML_ATTRIBUTES.update( + getattr(django_settings, "WIKI_MARKDOWN_HTML_ATTRIBUTES", {}) +) + +#: Allowed inline styles in Markdown article contents, default is no styles +#: (empty list). +MARKDOWN_HTML_STYLES = getattr(django_settings, "WIKI_MARKDOWN_HTML_STYLES", []) + +_project_defined_attrs = getattr( + django_settings, "WIKI_MARKDOWN_HTML_ATTRIBUTE_WHITELIST", False +) + +# If styles are allowed but no custom attributes are defined, we allow styles +# for all kinds of tags. +if MARKDOWN_HTML_STYLES and not _project_defined_attrs: + MARKDOWN_HTML_ATTRIBUTES["*"] = "style" + + # This slug is used in URLPath if an article has been deleted. The children of the # URLPath of that article are moved to lost and found. They keep their permissions # and all their content. diff --git a/wiki/core/__init__.py b/wiki/core/__init__.py index 64bc48c98..4ddaa37db 100644 --- a/wiki/core/__init__.py +++ b/wiki/core/__init__.py @@ -1,11 +1,27 @@ +import bleach import markdown +from wiki.conf import settings + class ArticleMarkdown(markdown.Markdown): - + def __init__(self, article, *args, **kwargs): - markdown.Markdown.__init__(self, *args, **kwargs) + super().__init__(*args, **kwargs) self.article = article + def convert(self, text, *args, **kwargs): + html = super().convert(text, *args, **kwargs) + if settings.MARKDOWN_SANITIZE_HTML: + html = bleach.clean( + html, + tags=settings.MARKDOWN_HTML_WHITELIST, + attributes=settings.MARKDOWN_HTML_ATTRIBUTES, + styles=settings.MARKDOWN_HTML_STYLES, + strip=True, + ) + return html + def article_markdown(text, article, *args, **kwargs): md = ArticleMarkdown(article, *args, **kwargs) - return md.convert(text) + html = md.convert(text) + return html diff --git a/wiki/core/compat.py b/wiki/core/compat.py new file mode 100644 index 000000000..ad73030e4 --- /dev/null +++ b/wiki/core/compat.py @@ -0,0 +1,14 @@ +# Django 1.11 Widget.build_attrs has a different signature, designed for the new +# template based rendering. The previous version was more useful for our needs, +# so we restore that version. +# When support for Django < 1.11 is dropped, we should look at using the +# new template based rendering, at which point this probably won't be needed at all. +class BuildAttrsCompat: + def build_attrs_compat(self, extra_attrs=None, **kwargs): + "Helper function for building an attribute dictionary." + attrs = self.attrs.copy() + if extra_attrs is not None: + attrs.update(extra_attrs) + if kwargs is not None: + attrs.update(kwargs) + return attrs diff --git a/wiki/core/diff.py b/wiki/core/diff.py index 061c543c4..4c5fd6f18 100644 --- a/wiki/core/diff.py +++ b/wiki/core/diff.py @@ -1,5 +1,6 @@ import difflib + def simple_merge(txt1, txt2): """Merges two texts""" differ = difflib.Differ(charjunk=difflib.IS_CHARACTER_JUNK) @@ -7,4 +8,4 @@ def simple_merge(txt1, txt2): content = "".join([l[2:] for l in diff]) - return content \ No newline at end of file + return content diff --git a/wiki/core/exceptions.py b/wiki/core/exceptions.py index a95264532..442b85c9d 100644 --- a/wiki/core/exceptions.py +++ b/wiki/core/exceptions.py @@ -1,4 +1,3 @@ - # If no root URL is found, we raise this... class NoRootURL(Exception): pass diff --git a/wiki/core/extensions.py b/wiki/core/extensions.py new file mode 100644 index 000000000..5d19e893c --- /dev/null +++ b/wiki/core/extensions.py @@ -0,0 +1,11 @@ +from markdown.extensions import Extension + +from wiki.core.processors import AnchorTagProcessor + + +class AnchorTagExtension(Extension): + """ + Custom extension to register anchor tag processor with Markdown. + """ + def extendMarkdown(self, md, md_globals): + md.treeprocessors.add('AnchorTagProcessor', AnchorTagProcessor(md), '>inline') diff --git a/wiki/core/http.py b/wiki/core/http.py index dd4e70d3e..c93833046 100644 --- a/wiki/core/http.py +++ b/wiki/core/http.py @@ -1,10 +1,11 @@ -import os import mimetypes +import os from datetime import datetime from django.http import HttpResponse -from django.utils.http import http_date from django.utils import dateformat +from django.utils.http import http_date + def send_file(request, filepath, last_modified=None, filename=None): fullpath = filepath @@ -16,7 +17,7 @@ def send_file(request, filepath, last_modified=None, filename=None): mimetype, encoding = mimetypes.guess_type(fullpath) mimetype = mimetype or 'application/octet-stream' - response = HttpResponse(open(fullpath, 'rb').read(), mimetype=mimetype) + response = HttpResponse(open(fullpath, 'rb').read(), content_type=mimetype) if not last_modified: response["Last-Modified"] = http_date(statobj.st_mtime) diff --git a/wiki/core/permissions.py b/wiki/core/permissions.py index 89658fcd9..307869c5c 100644 --- a/wiki/core/permissions.py +++ b/wiki/core/permissions.py @@ -1,15 +1,16 @@ from wiki.conf import settings + # Article settings. def can_assign(article, user): - return not user.is_anonymous() and settings.CAN_ASSIGN(article, user) + return not user.is_anonymous and settings.CAN_ASSIGN(article, user) def can_assign_owner(article, user): - return not user.is_anonymous() and settings.CAN_ASSIGN_OWNER(article, user) + return not user.is_anonymous and settings.CAN_ASSIGN_OWNER(article, user) def can_change_permissions(article, user): - return not user.is_anonymous() and settings.CAN_CHANGE_PERMISSIONS(article, user) + return not user.is_anonymous and settings.CAN_CHANGE_PERMISSIONS(article, user) def can_delete(article, user): - return not user.is_anonymous() and settings.CAN_DELETE(article, user) + return not user.is_anonymous and settings.CAN_DELETE(article, user) def can_moderate(article, user): - return not user.is_anonymous() and settings.CAN_MODERATE(article, user) + return not user.is_anonymous and settings.CAN_MODERATE(article, user) def can_admin(article, user): - return not user.is_anonymous() and settings.CAN_ADMIN(article, user) + return not user.is_anonymous and settings.CAN_ADMIN(article, user) diff --git a/wiki/core/plugins/base.py b/wiki/core/plugins/base.py index 62290b3d9..1c4e2cbbe 100644 --- a/wiki/core/plugins/base.py +++ b/wiki/core/plugins/base.py @@ -1,4 +1,5 @@ -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy as _ + """Base classes for different plugin objects. @@ -10,7 +11,7 @@ plugin's models. """ -class BasePlugin(object): +class BasePlugin: """Plugins should inherit from this""" # Must fill in! slug = None @@ -35,16 +36,15 @@ class RenderMedia: js = [] css = {} -class PluginSidebarFormMixin(object): +class PluginSidebarFormMixin: def get_usermessage(self): pass -class PluginSettingsFormMixin(object): - settings_form_headline = _(u'Settings for plugin') +class PluginSettingsFormMixin: + settings_form_headline = _('Settings for plugin') settings_order = 1 settings_write_access = False def get_usermessage(self): pass - diff --git a/wiki/core/plugins/loader.py b/wiki/core/plugins/loader.py index da5a2baaa..122acfc80 100644 --- a/wiki/core/plugins/loader.py +++ b/wiki/core/plugins/loader.py @@ -1,12 +1,14 @@ -# -*- coding: utf-8 -*- """ Credits to ojii, functions get_module and load are from: https://github.com/ojii/django-load. Thanks for the technique! """ + +from importlib import import_module + from django.conf import settings -from django.utils.importlib import import_module + def get_module(app, modname, verbose, failfast): """ @@ -15,14 +17,14 @@ def get_module(app, modname, verbose, failfast): module_name = '%s.%s' % (app, modname) try: module = import_module(module_name) - except ImportError, e: + except ImportError as e: if failfast: raise e elif verbose: - print "Could not load %r from %r: %s" % (modname, app, e) + print("Could not load %r from %r: %s" % (modname, app, e)) return None if verbose: - print "Loaded %r from %r" % (modname, app) + print("Loaded %r from %r" % (modname, app)) return module def load(modname, verbose=False, failfast=False): diff --git a/wiki/core/plugins/registry.py b/wiki/core/plugins/registry.py index ccd16c651..ecbb598f0 100644 --- a/wiki/core/plugins/registry.py +++ b/wiki/core/plugins/registry.py @@ -1,5 +1,4 @@ -# -*- coding: utf-8 -*- -from django.utils.importlib import import_module +from importlib import import_module _cache = {} _settings_forms = [] @@ -12,29 +11,29 @@ def register(PluginClass): Register a plugin class. This function will call back your plugin's constructor. """ - if PluginClass in _cache.keys(): + if PluginClass in list(_cache.keys()): raise Exception("Plugin class already registered") plugin = PluginClass() _cache[PluginClass] = plugin - + settings_form = getattr(PluginClass, 'settings_form', None) if settings_form: - if isinstance(settings_form, basestring): + if isinstance(settings_form, str): klassname = settings_form.split(".")[-1] modulename = ".".join(settings_form.split(".")[:-1]) form_module = import_module(modulename) settings_form = getattr(form_module, klassname) _settings_forms.append(settings_form) - - + + if getattr(PluginClass, 'article_tab', None): _article_tabs.append(plugin) - + if getattr(PluginClass, 'sidebar', None): _sidebar.append(plugin) - _markdown_extensions.extend(getattr(PluginClass, 'markdown_extensions', [])) - + _markdown_extensions.extend(getattr(PluginClass, 'markdown_extensions', [])) + def get_plugins(): """Get loaded plugins - do not call before all plugins are loaded.""" return _cache @@ -52,4 +51,4 @@ def get_sidebar(): return _sidebar def get_settings_forms(): - return _settings_forms \ No newline at end of file + return _settings_forms diff --git a/wiki/core/processors.py b/wiki/core/processors.py new file mode 100644 index 000000000..d1b00a8e6 --- /dev/null +++ b/wiki/core/processors.py @@ -0,0 +1,19 @@ +from markdown.treeprocessors import Treeprocessor + + +class AnchorTagProcessor(Treeprocessor): + """ + Custom treeprocessor to process the anchor tags in the HTML tree + """ + + def run(self, root): + anchor_tags = root.findall('.//a') + for a_tag in anchor_tags: + if not self.is_href_valid(a_tag.get('href')): + a_tag.set('href', '#') + + def is_href_valid(self, value): + """ + After mark down, validate if the JS is present inside the value of anchor tag. + """ + return not value.lower().startswith('javascript:') diff --git a/wiki/decorators.py b/wiki/decorators.py index ebe17161b..810f0fe1c 100644 --- a/wiki/decorators.py +++ b/wiki/decorators.py @@ -1,32 +1,35 @@ -# -*- coding: utf-8 -*- +import json + from django.conf import settings as django_settings -from django.core.urlresolvers import reverse -from django.http import HttpResponse, HttpResponseNotFound, \ - HttpResponseForbidden -from django.shortcuts import redirect, get_object_or_404 +from django.http import (HttpResponse, HttpResponseForbidden, + HttpResponseNotFound) +from django.shortcuts import get_object_or_404, redirect from django.template.context import RequestContext from django.template.loader import render_to_string -from django.utils import simplejson as json +from django.urls import reverse from wiki.core.exceptions import NoRootURL + def json_view(func): def wrap(request, *args, **kwargs): obj = func(request, *args, **kwargs) data = json.dumps(obj, ensure_ascii=False) status = kwargs.get('status', 200) - response = HttpResponse(mimetype='application/json', status=status) + response = HttpResponse(content_type='application/json', status=status) response.write(data) return response return wrap + def response_forbidden(request, article, urlpath): - if request.user.is_anonymous(): + if request.user.is_anonymous: return redirect(django_settings.LOGIN_URL) else: - c = RequestContext(request, {'article': article, - 'urlpath' : urlpath}) - return HttpResponseForbidden(render_to_string("wiki/permission_denied.html", context_instance=c)) + return HttpResponseForbidden( + render_to_string("wiki/permission_denied.html", context={'article': article, 'urlpath': urlpath}, + request=request)) + def get_article(func=None, can_read=True, can_write=False, deleted_contents=False, not_locked=False, @@ -55,7 +58,7 @@ def get_article(func=None, can_read=True, can_write=False, """ def wrapper(request, *args, **kwargs): - import models + from wiki import models path = kwargs.pop('path', None) article_id = kwargs.pop('article_id', None) @@ -70,13 +73,14 @@ def wrapper(request, *args, **kwargs): return redirect('wiki:root_create') except models.URLPath.DoesNotExist: try: - pathlist = filter(lambda x: x!="", path.split("/"),) + pathlist = list(filter(lambda x: x!="", path.split("/"),)) path = "/".join(pathlist[:-1]) parent = models.URLPath.get_by_path(path) return redirect(reverse("wiki:create", kwargs={'path': parent.path,}) + "?slug=%s" % pathlist[-1]) except models.URLPath.DoesNotExist: - c = RequestContext(request, {'error_type' : 'ancestors_missing'}) - return HttpResponseNotFound(render_to_string("wiki/error.html", context_instance=c)) + return HttpResponseNotFound( + render_to_string("wiki/error.html", context={'error_type': 'ancestors_missing'}, + request=request)) if urlpath.article: # urlpath is already smart about prefetching items on article (like current_revision), so we don't have to article = urlpath.article @@ -95,7 +99,7 @@ def wrapper(request, *args, **kwargs): article = get_object_or_404(articles, id=article_id) try: urlpath = models.URLPath.objects.get(articles__article=article) - except models.URLPath.DoesNotExist, models.URLPath.MultipleObjectsReturned: + except (models.URLPath.DoesNotExist, models.URLPath.MultipleObjectsReturned): urlpath = None @@ -138,4 +142,3 @@ def wrapper(request, *args, **kwargs): deleted_contents=deleted_contents, not_locked=not_locked,can_delete=can_delete, can_moderate=can_moderate) - diff --git a/wiki/editors/__init__.py b/wiki/editors/__init__.py index d7f08e008..a03d34e48 100644 --- a/wiki/editors/__init__.py +++ b/wiki/editors/__init__.py @@ -1,5 +1,6 @@ +from django.urls import get_callable + from wiki.conf import settings -from django.core.urlresolvers import get_callable _EditorClass = None _editor = None diff --git a/wiki/editors/base.py b/wiki/editors/base.py index 985ad5814..c7e589dc8 100644 --- a/wiki/editors/base.py +++ b/wiki/editors/base.py @@ -1,5 +1,6 @@ from django import forms + class BaseEditor(): """Editors should inherit from this. See wiki.editors for examples.""" @@ -22,5 +23,3 @@ class AdminMedia: class Media: css = {} js = () - - diff --git a/wiki/editors/markitup.py b/wiki/editors/markitup.py index b07be8623..399ab8379 100644 --- a/wiki/editors/markitup.py +++ b/wiki/editors/markitup.py @@ -1,12 +1,14 @@ from django import forms -from django.forms.util import flatatt -from django.utils.encoding import force_unicode +from django.forms.utils import flatatt +from django.utils.encoding import force_text from django.utils.html import conditional_escape from django.utils.safestring import mark_safe +from wiki.core.compat import BuildAttrsCompat from wiki.editors.base import BaseEditor -class MarkItUpAdminWidget(forms.Widget): + +class MarkItUpAdminWidget(BuildAttrsCompat, forms.Widget): """A simplified more fail-safe widget for the backend""" def __init__(self, attrs=None): # The 'rows' and 'cols' attributes are required for HTML correctness. @@ -14,28 +16,29 @@ def __init__(self, attrs=None): 'rows': '10', 'cols': '40',} if attrs: default_attrs.update(attrs) - super(MarkItUpAdminWidget, self).__init__(default_attrs) + super().__init__(default_attrs) def render(self, name, value, attrs=None): if value is None: value = '' - final_attrs = self.build_attrs(attrs, name=name) - return mark_safe(u'%s' % (flatatt(final_attrs), - conditional_escape(force_unicode(value)))) + final_attrs = self.build_attrs_compat(attrs, name=name) + return mark_safe('%s' % (flatatt(final_attrs), + conditional_escape(force_text(value)))) + -class MarkItUpWidget(forms.Widget): +class MarkItUpWidget(BuildAttrsCompat, forms.Widget): def __init__(self, attrs=None): # The 'rows' and 'cols' attributes are required for HTML correctness. default_attrs = {'class': 'markItUp', 'rows': '10', 'cols': '40',} if attrs: default_attrs.update(attrs) - super(MarkItUpWidget, self).__init__(default_attrs) + super().__init__(default_attrs) - def render(self, name, value, attrs=None): + def render(self, name, value, attrs=None, renderer=None): if value is None: value = '' - final_attrs = self.build_attrs(attrs, name=name) - return mark_safe(u'
%s
' % (flatatt(final_attrs), - conditional_escape(force_unicode(value)))) + final_attrs = self.build_attrs_compat(attrs, name=name) + return mark_safe('
%s
' % (flatatt(final_attrs), + conditional_escape(force_text(value)))) class MarkItUp(BaseEditor): editor_id = 'markitup' @@ -65,4 +68,3 @@ class Media: "wiki/markitup/jquery.markitup.js", "wiki/markitup/sets/frontend/set.js", ) - diff --git a/wiki/forms.py b/wiki/forms.py index 75f0a3244..c6a6e382f 100644 --- a/wiki/forms.py +++ b/wiki/forms.py @@ -1,21 +1,22 @@ -# -*- coding: utf-8 -*- +from itertools import chain + from django import forms -from django.utils.translation import ugettext as _ +from django.contrib.auth.models import User +from django.forms.utils import flatatt +from django.forms.widgets import HiddenInput +from django.utils.encoding import force_text +from django.utils.html import conditional_escape, escape, strip_tags from django.utils.safestring import mark_safe -from django.forms.util import flatatt -from django.utils.encoding import force_unicode -from django.utils.html import escape, conditional_escape - -from itertools import chain +from django.utils.translation import ugettext_lazy as _ from wiki import models from wiki.conf import settings -from wiki.editors import getEditor +from wiki.core import permissions +from wiki.core.compat import BuildAttrsCompat from wiki.core.diff import simple_merge -from django.forms.widgets import HiddenInput from wiki.core.plugins.base import PluginSettingsFormMixin -from django.contrib.auth.models import User -from wiki.core import permissions +from wiki.editors import getEditor + class SpamProtectionMixin(): @@ -33,19 +34,19 @@ def check_spam(self, current_revision, request): class CreateRootForm(forms.Form): - title = forms.CharField(label=_(u'Title'), help_text=_(u'Initial title of the article. May be overridden with revision titles.')) - content = forms.CharField(label=_(u'Type in some contents'), - help_text=_(u'This is just the initial contents of your article. After creating it, you can use more complex features like adding plugins, meta data, related articles etc...'), + title = forms.CharField(label=_('Title'), help_text=_('Initial title of the article. May be overridden with revision titles.')) + content = forms.CharField(label=_('Type in some contents'), + help_text=_('This is just the initial contents of your article. After creating it, you can use more complex features like adding plugins, meta data, related articles etc...'), required=False, widget=getEditor().get_widget()) #@UndefinedVariable class EditForm(forms.Form): - title = forms.CharField(label=_(u'Title'),) - content = forms.CharField(label=_(u'Contents'), + title = forms.CharField(label=_('Title'),) + content = forms.CharField(label=_('Contents'), required=False, widget=getEditor().get_widget()) #@UndefinedVariable - summary = forms.CharField(label=_(u'Summary'), help_text=_(u'Give a short reason for your edit, which will be stated in the revision log.'), + summary = forms.CharField(label=_('Summary'), help_text=_('Give a short reason for your edit, which will be stated in the revision log.'), required=False) current_revision = forms.IntegerField(required=False, widget=forms.HiddenInput()) @@ -83,20 +84,24 @@ def __init__(self, current_revision, *args, **kwargs): kwargs['initial'] = initial - super(EditForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + def clean_title(self): + title = strip_tags(self.cleaned_data['title']) + return title + def clean(self): cd = self.cleaned_data if self.no_clean or self.preview: return cd if not str(self.initial_revision.id) == str(self.presumed_revision): - raise forms.ValidationError(_(u'While you were editing, someone else changed the revision. Your contents have been automatically merged with the new contents. Please review the text below.')) + raise forms.ValidationError(_('While you were editing, someone else changed the revision. Your contents have been automatically merged with the new contents. Please review the text below.')) if cd['title'] == self.initial_revision.title and cd['content'] == self.initial_revision.content: - raise forms.ValidationError(_(u'No changes made. Nothing to save.')) + raise forms.ValidationError(_('No changes made. Nothing to save.')) return cd -class SelectWidgetBootstrap(forms.Select): +class SelectWidgetBootstrap(BuildAttrsCompat, forms.Select): """ http://twitter.github.com/bootstrap/components.html#buttonDropdowns Needs bootstrap and jquery @@ -130,16 +135,16 @@ class SelectWidgetBootstrap(forms.Select): """) def __init__(self, attrs={'class': 'btn-group pull-left btn-group-form'}, choices=()): self.noscript_widget = forms.Select(attrs={}, choices=choices) - super(SelectWidgetBootstrap, self).__init__(attrs, choices) + super().__init__(attrs, choices) def __setattr__(self, k, value): - super(SelectWidgetBootstrap, self).__setattr__(k, value) + super().__setattr__(k, value) if k != 'attrs': self.noscript_widget.__setattr__(k, value) def render(self, name, value, attrs=None, choices=()): if value is None: value = '' - final_attrs = self.build_attrs(attrs, name=name) + final_attrs = self.build_attrs_compat(attrs, name=name) output = ["""""" """ """ """