diff --git a/.travis.yml b/.travis.yml index f18f5ced8..5142c38dd 100755 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,10 @@ language: python python: - "3.6" + - "3.7" env: - - DJANGO_VERSION=2.0.7 - - DJANGO_VERSION=1.11.14 + - DJANGO_VERSION=2.2.9 + - DJANGO_VERSION=3.0.3 branches: only: - master diff --git a/README.rst b/README.rst index 5c960ac5d..0aefc68c8 100755 --- a/README.rst +++ b/README.rst @@ -28,15 +28,15 @@ The project has four basic apps: * News (A Twitter-like microblog) * Articles (A collaborative blog) * Question & Answers (A Stack Overflow-like platform) -* Messeger (A basic chat-a-like tool for asynchronous communication.) +* Messenger (A basic chat-a-like tool for asynchronous communication.) Technology Stack ---------------- * Python_ 3.6.x / 3.7.x -* `Django Web Framework`_ 1.11.x / 2.0.x +* `Django Web Framework`_ 2.2.x * PostgreSQL_ -* `Redis 3.2`_ +* `Redis 5.0`_ * Daphne_ * Caddy_ * Docker_ @@ -52,7 +52,7 @@ Technology Stack .. _Python: https://www.python.org/ .. _`Django Web Framework`: https://www.djangoproject.com/ .. _PostgreSQL: https://www.postgresql.org/ -.. _`Redis 3.2`: https://redis.io/documentation +.. _`Redis 5.0`: https://redis.io/documentation .. _Daphne: https://github.com/django/daphne/ .. _Caddy: https://caddyserver.com/docs .. _Docker: https://docs.docker.com/ diff --git a/bootcamp/__init__.py b/bootcamp/__init__.py index dc0c5c188..3a64db1dc 100755 --- a/bootcamp/__init__.py +++ b/bootcamp/__init__.py @@ -1,2 +1,7 @@ -__version__ = '2.0.0' -__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) +__version__ = "2.1.1" +__version_info__ = tuple( + [ + int(num) if num.isdigit() else num + for num in __version__.replace("-", ".", 1).split(".") + ] +) diff --git a/bootcamp/articles/admin.py b/bootcamp/articles/admin.py index b91078594..d9023af88 100755 --- a/bootcamp/articles/admin.py +++ b/bootcamp/articles/admin.py @@ -4,5 +4,5 @@ @admin.register(Article) class ArticleAdmin(admin.ModelAdmin): - list_display = ('title', 'user', 'status') - list_filter = ('user', 'status', 'timestamp') + list_display = ("title", "user", "status") + list_filter = ("user", "status", "timestamp") diff --git a/bootcamp/articles/apps.py b/bootcamp/articles/apps.py index 037a11955..479d6eb68 100755 --- a/bootcamp/articles/apps.py +++ b/bootcamp/articles/apps.py @@ -3,5 +3,5 @@ class ArticlesConfig(AppConfig): - name = 'bootcamp.articles' - verbose_name = _('Articles') + name = "bootcamp.articles" + verbose_name = _("Articles") diff --git a/bootcamp/articles/forms.py b/bootcamp/articles/forms.py index 8cf1e6a27..cc455be46 100755 --- a/bootcamp/articles/forms.py +++ b/bootcamp/articles/forms.py @@ -8,7 +8,8 @@ class ArticleForm(forms.ModelForm): status = forms.CharField(widget=forms.HiddenInput()) edited = forms.BooleanField( - widget=forms.HiddenInput(), required=False, initial=False) + widget=forms.HiddenInput(), required=False, initial=False + ) content = MarkdownxFormField() class Meta: diff --git a/bootcamp/articles/migrations/0001_initial.py b/bootcamp/articles/migrations/0001_initial.py index 8345dd551..bb47a8e98 100755 --- a/bootcamp/articles/migrations/0001_initial.py +++ b/bootcamp/articles/migrations/0001_initial.py @@ -12,29 +12,66 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('taggit', '0002_auto_20150616_2121'), + ("taggit", "0002_auto_20150616_2121"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Article', + name="Article", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('image', models.ImageField(upload_to='articles_pictures/%Y/%m/%d/', verbose_name='Featured image')), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('title', models.CharField(max_length=255, unique=True)), - ('slug', models.SlugField(blank=True, max_length=80, null=True)), - ('status', models.CharField(choices=[('D', 'Draft'), ('P', 'Published')], default='D', max_length=1)), - ('content', markdownx.models.MarkdownxField()), - ('edited', models.BooleanField(default=False)), - ('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='author', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "image", + models.ImageField( + upload_to="articles_pictures/%Y/%m/%d/", + verbose_name="Featured image", + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("title", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField(blank=True, max_length=80, null=True)), + ( + "status", + models.CharField( + choices=[("D", "Draft"), ("P", "Published")], + default="D", + max_length=1, + ), + ), + ("content", markdownx.models.MarkdownxField()), + ("edited", models.BooleanField(default=False)), + ( + "tags", + taggit.managers.TaggableManager( + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="author", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Article', - 'verbose_name_plural': 'Articles', - 'ordering': ('-timestamp',), + "verbose_name": "Article", + "verbose_name_plural": "Articles", + "ordering": ("-timestamp",), }, - ), + ) ] diff --git a/bootcamp/articles/models.py b/bootcamp/articles/models.py index 71ff509ce..44fdb22d9 100755 --- a/bootcamp/articles/models.py +++ b/bootcamp/articles/models.py @@ -27,8 +27,9 @@ def get_drafts(self): def get_counted_tags(self): tag_dict = {} - query = self.filter(status='P').annotate( - tagged=Count('tags')).filter(tags__gt=0) + query = ( + self.filter(status="P").annotate(tagged=Count("tags")).filter(tags__gt=0) + ) for obj in query: for tag in obj.tags.names(): if tag not in tag_dict: @@ -43,16 +44,17 @@ def get_counted_tags(self): class Article(models.Model): DRAFT = "D" PUBLISHED = "P" - STATUS = ( - (DRAFT, _("Draft")), - (PUBLISHED, _("Published")), - ) + STATUS = ((DRAFT, _("Draft")), (PUBLISHED, _("Published"))) user = models.ForeignKey( - settings.AUTH_USER_MODEL, null=True, related_name="author", - on_delete=models.SET_NULL) + settings.AUTH_USER_MODEL, + null=True, + related_name="author", + on_delete=models.SET_NULL, + ) image = models.ImageField( - _('Featured image'), upload_to='articles_pictures/%Y/%m/%d/') + _("Featured image"), upload_to="articles_pictures/%Y/%m/%d/" + ) timestamp = models.DateTimeField(auto_now_add=True) title = models.CharField(max_length=255, null=False, unique=True) slug = models.SlugField(max_length=80, null=True, blank=True) @@ -72,8 +74,9 @@ def __str__(self): def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(f"{self.user.username}-{self.title}", - to_lower=True, max_length=80) + self.slug = slugify( + f"{self.user.username}-{self.title}", lowercase=True, max_length=80 + ) super().save(*args, **kwargs) @@ -81,15 +84,13 @@ def get_markdown(self): return markdownify(self.content) -def notify_comment(**kwargs): +def notify_comment(**kwargs): # pragma: no cover """Handler to be fired up upon comments signal to notify the author of a given article.""" - actor = kwargs['request'].user - receiver = kwargs['comment'].content_object.user - obj = kwargs['comment'].content_object - notification_handler( - actor, receiver, Notification.COMMENTED, action_object=obj - ) + actor = kwargs["request"].user + receiver = kwargs["comment"].content_object.user + obj = kwargs["comment"].content_object + notification_handler(actor, receiver, Notification.COMMENTED, action_object=obj) comment_was_posted.connect(receiver=notify_comment) diff --git a/bootcamp/articles/tests/test_models.py b/bootcamp/articles/tests/test_models.py index e8e4fe7f0..2620f6aa6 100755 --- a/bootcamp/articles/tests/test_models.py +++ b/bootcamp/articles/tests/test_models.py @@ -13,6 +13,7 @@ def setUp(self): status="P", user=self.user, ) + self.article.tags.add("test1", "test2") self.not_p_article = Article.objects.create( title="A really nice to-be title", content="""This is a really good content, just if somebody @@ -22,6 +23,7 @@ def setUp(self): everybody always wants the real deal.""", user=self.user, ) + self.not_p_article.tags.add("test1", "test2") def test_object_instance(self): assert isinstance(self.article, Article) @@ -36,3 +38,13 @@ def test_return_values(self): assert self.article in Article.objects.get_published() assert Article.objects.get_published()[0].title == "A really nice title" assert self.not_p_article in Article.objects.get_drafts() + + def test_get_popular_tags(self): + correct_dict = {"test1": 1, "test2": 1} + assert Article.objects.get_counted_tags() == correct_dict.items() + + def test_change_draft_title(self): + assert self.not_p_article.title == "A really nice to-be title" + self.not_p_article.title = "A really nice changed title" + self.not_p_article.save() + assert self.not_p_article.title == "A really nice changed title" diff --git a/bootcamp/articles/tests/test_views.py b/bootcamp/articles/tests/test_views.py index 512684ddd..69005dc75 100755 --- a/bootcamp/articles/tests/test_views.py +++ b/bootcamp/articles/tests/test_views.py @@ -53,29 +53,38 @@ def test_index_articles(self): self.assertEqual(response.status_code, 200) def test_error_404(self): - response_no_art = self.client.get(reverse( - "articles:article", kwargs={"slug": "no-slug"})) + response_no_art = self.client.get( + reverse("articles:article", kwargs={"slug": "no-slug"}) + ) self.assertEqual(response_no_art.status_code, 404) @override_settings(MEDIA_ROOT=tempfile.gettempdir()) def test_create_article(self): - response = self.client.post(reverse("articles:write_new"), - {"title": "A not that really nice title", - "content": "Whatever works for you", - "tags": "list, lists", - "status": "P", - "image": self.test_image}) + response = self.client.post( + reverse("articles:write_new"), + { + "title": "A not that really nice title", + "content": "Whatever works for you", + "tags": "list, lists", + "status": "P", + "image": self.test_image, + }, + ) assert response.status_code == 302 @override_settings(MEDIA_ROOT=tempfile.gettempdir()) def test_single_article(self): current_count = Article.objects.count() - response = self.client.post(reverse("articles:write_new"), - {"title": "A not that really nice title", - "content": "Whatever works for you", - "tags": "list, lists", - "status": "P", - "image": self.test_image}) + response = self.client.post( + reverse("articles:write_new"), + { + "title": "A not that really nice title", + "content": "Whatever works for you", + "tags": "list, lists", + "status": "P", + "image": self.test_image, + }, + ) # response_art = self.client.get( # reverse("articles:article", # kwargs={"slug": "a-not-that-really-nice-title"})) @@ -85,13 +94,40 @@ def test_single_article(self): @override_settings(MEDIA_ROOT=tempfile.gettempdir()) def test_draft_article(self): - response = self.client.post(reverse("articles:write_new"), - {"title": "A not that really nice title", - "content": "Whatever works for you", - "tags": "list, lists", - "status": "D", - "image": self.test_image}) + response = self.client.post( + reverse("articles:write_new"), + { + "title": "A not that really nice title", + "content": "Whatever works for you", + "tags": "list, lists", + "status": "D", + "image": self.test_image, + }, + ) + resp = self.client.get(reverse("articles:drafts")) + assert resp.status_code == 200 + assert response.status_code == 302 + assert ( + resp.context["articles"][0].slug + == "first-user-a-not-that-really-nice-title" + ) + + @override_settings(MEDIA_ROOT=tempfile.gettempdir()) + def test_draft_article_change(self): + response = self.client.post( + reverse("articles:edit_article", kwargs={"pk": self.not_p_article.id}), + { + "title": "A really nice changed title", + "content": "Whatever works for you", + "tags": "list, lists", + "status": "D", + "image": self.test_image, + }, + ) resp = self.client.get(reverse("articles:drafts")) assert resp.status_code == 200 assert response.status_code == 302 - assert resp.context["articles"][0].slug == "first-user-a-not-that-really-nice-title" + assert resp.context["articles"][0].title == "A really nice changed title" + assert ( + resp.context["articles"][0].slug == "first-user-a-really-nice-to-be-title" + ) diff --git a/bootcamp/articles/urls.py b/bootcamp/articles/urls.py index b73d3410b..74a286506 100755 --- a/bootcamp/articles/urls.py +++ b/bootcamp/articles/urls.py @@ -1,13 +1,18 @@ from django.conf.urls import url -from bootcamp.articles.views import (ArticlesListView, DraftsListView, - CreateArticleView, EditArticleView, - DetailArticleView) -app_name = 'articles' +from bootcamp.articles.views import ( + ArticlesListView, + DraftsListView, + CreateArticleView, + EditArticleView, + DetailArticleView, +) + +app_name = "articles" urlpatterns = [ - url(r'^$', ArticlesListView.as_view(), name='list'), - url(r'^write-new-article/$', CreateArticleView.as_view(), name='write_new'), - url(r'^drafts/$', DraftsListView.as_view(), name='drafts'), - url(r'^edit/(?P\d+)/$', EditArticleView.as_view(), name='edit_article'), - url(r'^(?P[-\w]+)/$', DetailArticleView.as_view(), name='article'), + url(r"^$", ArticlesListView.as_view(), name="list"), + url(r"^write-new-article/$", CreateArticleView.as_view(), name="write_new"), + url(r"^drafts/$", DraftsListView.as_view(), name="drafts"), + url(r"^edit/(?P\d+)/$", EditArticleView.as_view(), name="edit_article"), + url(r"^(?P[-\w]+)/$", DetailArticleView.as_view(), name="article"), ] diff --git a/bootcamp/articles/views.py b/bootcamp/articles/views.py index b6ac4e54c..c7013c717 100755 --- a/bootcamp/articles/views.py +++ b/bootcamp/articles/views.py @@ -11,13 +11,14 @@ class ArticlesListView(LoginRequiredMixin, ListView): """Basic ListView implementation to call the published articles list.""" + model = Article paginate_by = 15 context_object_name = "articles" def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['popular_tags'] = Article.objects.get_counted_tags() + context["popular_tags"] = Article.objects.get_counted_tags() return context def get_queryset(self, **kwargs): @@ -27,16 +28,18 @@ def get_queryset(self, **kwargs): class DraftsListView(ArticlesListView): """Overriding the original implementation to call the drafts articles list.""" + def get_queryset(self, **kwargs): return Article.objects.get_drafts() class CreateArticleView(LoginRequiredMixin, CreateView): """Basic CreateView implementation to create new articles.""" + model = Article message = _("Your article has been created.") form_class = ArticleForm - template_name = 'articles/article_create.html' + template_name = "articles/article_create.html" def form_valid(self, form): form.instance.user = self.request.user @@ -44,15 +47,16 @@ def form_valid(self, form): def get_success_url(self): messages.success(self.request, self.message) - return reverse('articles:list') + return reverse("articles:list") class EditArticleView(LoginRequiredMixin, AuthorRequiredMixin, UpdateView): """Basic EditView implementation to edit existing articles.""" + model = Article message = _("Your article has been updated.") form_class = ArticleForm - template_name = 'articles/article_update.html' + template_name = "articles/article_update.html" def form_valid(self, form): form.instance.user = self.request.user @@ -60,9 +64,10 @@ def form_valid(self, form): def get_success_url(self): messages.success(self.request, self.message) - return reverse('articles:list') + return reverse("articles:list") class DetailArticleView(LoginRequiredMixin, DetailView): """Basic DetailView implementation to call an individual article.""" + model = Article diff --git a/bootcamp/contrib/sites/migrations/0001_initial.py b/bootcamp/contrib/sites/migrations/0001_initial.py index a7639869f..304cd6d7c 100755 --- a/bootcamp/contrib/sites/migrations/0001_initial.py +++ b/bootcamp/contrib/sites/migrations/0001_initial.py @@ -9,23 +9,34 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Site', + name="Site", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('domain', models.CharField( - max_length=100, verbose_name='domain name', validators=[_simple_domain_name_validator] - )), - ('name', models.CharField(max_length=50, verbose_name='display name')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ( + "domain", + models.CharField( + max_length=100, + verbose_name="domain name", + validators=[_simple_domain_name_validator], + ), + ), + ("name", models.CharField(max_length=50, verbose_name="display name")), ], options={ - 'ordering': ('domain',), - 'db_table': 'django_site', - 'verbose_name': 'site', - 'verbose_name_plural': 'sites', + "ordering": ("domain",), + "db_table": "django_site", + "verbose_name": "site", + "verbose_name_plural": "sites", }, bases=(models.Model,), - managers=[ - ('objects', django.contrib.sites.models.SiteManager()), - ], - ), + managers=[("objects", django.contrib.sites.models.SiteManager())], + ) ] diff --git a/bootcamp/contrib/sites/migrations/0002_alter_domain_unique.py b/bootcamp/contrib/sites/migrations/0002_alter_domain_unique.py index 6a26ebcde..2c8d6dac0 100755 --- a/bootcamp/contrib/sites/migrations/0002_alter_domain_unique.py +++ b/bootcamp/contrib/sites/migrations/0002_alter_domain_unique.py @@ -4,17 +4,17 @@ class Migration(migrations.Migration): - dependencies = [ - ('sites', '0001_initial'), - ] + dependencies = [("sites", "0001_initial")] operations = [ migrations.AlterField( - model_name='site', - name='domain', + model_name="site", + name="domain", field=models.CharField( - max_length=100, unique=True, validators=[django.contrib.sites.models._simple_domain_name_validator], - verbose_name='domain name' + max_length=100, + unique=True, + validators=[django.contrib.sites.models._simple_domain_name_validator], + verbose_name="domain name", ), - ), + ) ] diff --git a/bootcamp/contrib/sites/migrations/0003_set_site_domain_and_name.py b/bootcamp/contrib/sites/migrations/0003_set_site_domain_and_name.py index c93f8e1d9..4291e1d5d 100755 --- a/bootcamp/contrib/sites/migrations/0003_set_site_domain_and_name.py +++ b/bootcamp/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -9,34 +9,26 @@ def update_site_forward(apps, schema_editor): """Set site domain and name.""" - Site = apps.get_model('sites', 'Site') + Site = apps.get_model("sites", "Site") Site.objects.update_or_create( id=settings.SITE_ID, defaults={ - 'domain': 'vitor@freitas.com trybootcamp.vitorfs.com', - 'name': 'Bootcamp' - } + "domain": "vitor@freitas.com trybootcamp.vitorfs.com", + "name": "Bootcamp", + }, ) def update_site_backward(apps, schema_editor): """Revert site domain and name to default.""" - Site = apps.get_model('sites', 'Site') + Site = apps.get_model("sites", "Site") Site.objects.update_or_create( - id=settings.SITE_ID, - defaults={ - 'domain': 'example.com', - 'name': 'example.com' - } + id=settings.SITE_ID, defaults={"domain": "example.com", "name": "example.com"} ) class Migration(migrations.Migration): - dependencies = [ - ('sites', '0002_alter_domain_unique'), - ] + dependencies = [("sites", "0002_alter_domain_unique")] - operations = [ - migrations.RunPython(update_site_forward, update_site_backward), - ] + operations = [migrations.RunPython(update_site_forward, update_site_backward)] diff --git a/bootcamp/helpers.py b/bootcamp/helpers.py index 34858da68..bca72b6c2 100755 --- a/bootcamp/helpers.py +++ b/bootcamp/helpers.py @@ -1,7 +1,14 @@ +import re +from urllib.parse import urljoin, urlparse + from django.core.exceptions import PermissionDenied -from django.http import HttpResponseBadRequest -from django.views.generic import View from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator +from django.http import HttpResponseBadRequest, JsonResponse +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import View + +import bs4 +import requests def paginate_data(qs, page_size, page, paginated_type, **kwargs): @@ -23,12 +30,13 @@ def paginate_data(qs, page_size, page, paginated_type, **kwargs): has_next=page_obj.has_next(), has_prev=page_obj.has_previous(), objects=page_obj.object_list, - **kwargs + **kwargs, ) def ajax_required(f): """Not a mixin, but a nice decorator to validate than a request is AJAX""" + def wrap(request, *args, **kwargs): if not request.is_ajax(): return HttpResponseBadRequest() @@ -43,9 +51,130 @@ def wrap(request, *args, **kwargs): class AuthorRequiredMixin(View): """Mixin to validate than the loggedin user is the creator of the object to be edited or updated.""" + def dispatch(self, request, *args, **kwargs): obj = self.get_object() if obj.user != self.request.user: raise PermissionDenied return super().dispatch(request, *args, **kwargs) + + +def is_owner(obj, username): + """ + Checks if model instance belongs to a user + Args: + obj: A model instance + username(str): User's username + Returns: + boolean: True is model instance belongs to user else False + """ + if obj.user.username == username: + return True + return False + + +def update_votes(obj, user, value): + """ + Updates votes for either a question or answer + Args: + obj: Question or Answer model instance + user: User model instance voting an anwser or question + value(str): 'U' for an up vote or 'D' for down vote + """ + obj.votes.update_or_create( + user=user, defaults={"value": value}, + ) + obj.count_votes() + + +def fetch_metadata(text): + """Method to consolidate workflow to recover the metadata of a page of the first URL found a in + a given text block. + :requieres: + + :param text: Block of text of any lenght + """ + urls = get_urls(text) + try: + return get_metadata(urls[0]) + + except IndexError: + return None + + +def get_urls(text): + """Method to look for all URLs in a given text, extract them and return them as a tuple of urls. + :requires: + + :param text: A valid block of text of any lenght. + + :returns: + A tuple of valid URLs extracted from the text. + """ + regex = ( + regex + ) = r"(?i)\b((?:https?:(?:\/{1,3}|[a-z0-9%])|[a-z0-9.\-]+[.](?:com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|Ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)\/)(?:[^\s()<>{}\[\]]+|\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\))+(?:\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\)|[^\s`!()\[\]{};:\'\".,<>?«»“”‘’])|(?:(? 0: + data["image"] = urljoin(url, images[0].get("src")) + + if not data.get("description"): + data["description"] = "" + for text in soup.body.find_all(string=True): + if ( + text.parent.name != "script" + and text.parent.name != "style" + and not isinstance(text, bs4.Comment) + ): + data["description"] += text + + data["description"] = re.sub("\n|\r|\t", " ", data["description"]) + data["description"] = re.sub(" +", " ", data["description"]) + data["description"] = data["description"].strip()[:255] + + return data diff --git a/bootcamp/messager/apps.py b/bootcamp/messager/apps.py index 493d73a62..c0885accb 100755 --- a/bootcamp/messager/apps.py +++ b/bootcamp/messager/apps.py @@ -3,5 +3,5 @@ class MessagerConfig(AppConfig): - name = 'bootcamp.messager' - verbose_name = _('Messager') + name = "bootcamp.messager" + verbose_name = _("Messager") diff --git a/bootcamp/messager/consumers.py b/bootcamp/messager/consumers.py index 457d126d4..e713a9da9 100755 --- a/bootcamp/messager/consumers.py +++ b/bootcamp/messager/consumers.py @@ -6,6 +6,7 @@ class MessagerConsumer(AsyncWebsocketConsumer): """Consumer to manage WebSocket connections for the Messager app. """ + async def connect(self): """Consumer Connect implementation, to validate user status and prevent non authenticated user to take advante from the connection.""" @@ -15,13 +16,17 @@ async def connect(self): else: # Accept the connection - await self.channel_layer.group_add(f"{self.scope['user'].username}", self.channel_name) + await self.channel_layer.group_add( + f"{self.scope['user'].username}", self.channel_name + ) await self.accept() async def disconnect(self, close_code): """Consumer implementation to leave behind the group at the moment the closes the connection.""" - await self.channel_layer.group_discard(f"{self.scope['user'].username}", self.channel_name) + await self.channel_layer.group_discard( + f"{self.scope['user'].username}", self.channel_name + ) async def receive(self, text_data): """Receive method implementation to redirect any new message received diff --git a/bootcamp/messager/migrations/0001_initial.py b/bootcamp/messager/migrations/0001_initial.py index 380bb6382..74a7e1651 100755 --- a/bootcamp/messager/migrations/0001_initial.py +++ b/bootcamp/messager/migrations/0001_initial.py @@ -10,25 +10,50 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( - name='Message', + name="Message", fields=[ - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('message', models.TextField(blank=True, max_length=1000)), - ('unread', models.BooleanField(db_index=True, default=True)), - ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), - ('sender', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("message", models.TextField(blank=True, max_length=1000)), + ("unread", models.BooleanField(db_index=True, default=True)), + ( + "recipient", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="received_messages", + to=settings.AUTH_USER_MODEL, + verbose_name="Recipient", + ), + ), + ( + "sender", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sent_messages", + to=settings.AUTH_USER_MODEL, + verbose_name="Sender", + ), + ), ], options={ - 'verbose_name': 'Message', - 'verbose_name_plural': 'Messages', - 'ordering': ('-timestamp',), + "verbose_name": "Message", + "verbose_name_plural": "Messages", + "ordering": ("-timestamp",), }, - ), + ) ] diff --git a/bootcamp/messager/models.py b/bootcamp/messager/models.py index 654a18ec1..b8645a5b1 100755 --- a/bootcamp/messager/models.py +++ b/bootcamp/messager/models.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.db import transaction from asgiref.sync import async_to_sync @@ -17,7 +18,7 @@ def get_conversation(self, sender, recipient): """Returns all the messages sent between two users.""" qs_one = self.filter(sender=sender, recipient=recipient) qs_two = self.filter(sender=recipient, recipient=sender) - return qs_one.union(qs_two).order_by('timestamp') + return qs_one.union(qs_two).order_by("timestamp") def get_most_recent_conversation(self, recipient): """Returns the most recent conversation counterpart's username.""" @@ -41,14 +42,23 @@ def mark_conversation_as_read(self, sender, recipient): class Message(models.Model): """A private message sent between users.""" - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) + + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) sender = models.ForeignKey( - settings.AUTH_USER_MODEL, related_name='sent_messages', - verbose_name=_("Sender"), null=True, on_delete=models.SET_NULL) + settings.AUTH_USER_MODEL, + related_name="sent_messages", + verbose_name=_("Sender"), + null=True, + on_delete=models.SET_NULL, + ) recipient = models.ForeignKey( - settings.AUTH_USER_MODEL, related_name='received_messages', null=True, - blank=True, verbose_name=_("Recipient"), on_delete=models.SET_NULL) + settings.AUTH_USER_MODEL, + related_name="received_messages", + null=True, + blank=True, + verbose_name=_("Recipient"), + on_delete=models.SET_NULL, + ) timestamp = models.DateTimeField(auto_now_add=True) message = models.TextField(max_length=1000, blank=True) unread = models.BooleanField(default=True, db_index=True) @@ -57,7 +67,7 @@ class Message(models.Model): class Meta: verbose_name = _("Message") verbose_name_plural = _("Messages") - ordering = ("-timestamp", ) + ordering = ("-timestamp",) def __str__(self): return self.message @@ -79,17 +89,17 @@ def send_message(sender, recipient, message): actual message. """ new_message = Message.objects.create( - sender=sender, - recipient=recipient, - message=message + sender=sender, recipient=recipient, message=message ) channel_layer = get_channel_layer() payload = { - 'type': 'receive', - 'key': 'message', - 'message_id': new_message.uuid_id, - 'sender': sender, - 'recipient': recipient - } - async_to_sync(channel_layer.group_send)(recipient.username, payload) + "type": "receive", + "key": "message", + "message_id": str(new_message.uuid_id), + "sender": str(sender), + "recipient": str(recipient), + } + transaction.on_commit( + lambda: async_to_sync(channel_layer.group_send)(recipient.username, payload) + ) return new_message diff --git a/bootcamp/messager/schema.py b/bootcamp/messager/schema.py index 92bdd3e02..ca4b14d04 100755 --- a/bootcamp/messager/schema.py +++ b/bootcamp/messager/schema.py @@ -15,6 +15,7 @@ class Meta: class MessageQuery(object): """Abstract object to register in the root schema, allowing to query the model.""" + conversation = graphene.List(MessageType) def resolve_conversation(self, info, **kwargs): @@ -24,7 +25,7 @@ def resolve_conversation(self, info, **kwargs): return Message.objects.get_conversation(sender, recipient) def resolve_message(self, info, **kwargs): - uuid_id = kwargs.get('uuid_id') + uuid_id = kwargs.get("uuid_id") if uuid_id is not None: return Message.objects.get(uuid_id=uuid_id) diff --git a/bootcamp/messager/tests/test_models.py b/bootcamp/messager/tests/test_models.py index 196222af8..a081b7d0d 100755 --- a/bootcamp/messager/tests/test_models.py +++ b/bootcamp/messager/tests/test_models.py @@ -10,17 +10,13 @@ def setUp(self): self.first_message = Message.objects.create( sender=self.user, recipient=self.other_user, - message="A not that long message." + message="A not that long message.", ) self.second_message = Message.objects.create( - sender=self.user, - recipient=self.other_user, - message="A follow up message." + sender=self.user, recipient=self.other_user, message="A follow up message." ) self.third_message = Message.objects.create( - sender=self.other_user, - recipient=self.user, - message="An answer message." + sender=self.other_user, recipient=self.user, message="An answer message." ) def test_object_instance(self): @@ -31,13 +27,11 @@ def test_return_values(self): assert self.first_message.message == "A not that long message." def test_conversation(self): - conversation = Message.objects.get_conversation( - self.user, self.other_user) + conversation = Message.objects.get_conversation(self.user, self.other_user) assert conversation.last().message == "An answer message." def test_recent_conversation(self): - active_user = Message.objects.get_most_recent_conversation( - self.user) + active_user = Message.objects.get_most_recent_conversation(self.user) assert active_user == self.other_user def test_single_marking_as_read(self): @@ -47,7 +41,5 @@ def test_single_marking_as_read(self): def test_sending_new_message(self): initial_count = Message.objects.count() - Message.send_message( - self.other_user, self.user, "A follow up answer message." - ) + Message.send_message(self.other_user, self.user, "A follow up answer message.") assert Message.objects.count() == initial_count + 1 diff --git a/bootcamp/messager/tests/test_views.py b/bootcamp/messager/tests/test_views.py index b78197c24..609ceb399 100755 --- a/bootcamp/messager/tests/test_views.py +++ b/bootcamp/messager/tests/test_views.py @@ -17,17 +17,13 @@ def setUp(self): self.first_message = Message.objects.create( sender=self.user, recipient=self.other_user, - message="A not that long message." + message="A not that long message.", ) self.second_message = Message.objects.create( - sender=self.user, - recipient=self.other_user, - message="A follow up message." + sender=self.user, recipient=self.other_user, message="A follow up message." ) self.third_message = Message.objects.create( - sender=self.other_user, - recipient=self.user, - message="An answer message." + sender=self.other_user, recipient=self.user, message="An answer message." ) def test_user_messages(self): @@ -37,50 +33,61 @@ def test_user_messages(self): def test_user_conversation(self): response = self.client.get( - reverse("messager:conversation_detail", - kwargs={"username": self.user.username})) + reverse( + "messager:conversation_detail", kwargs={"username": self.user.username} + ) + ) assert response.status_code == 200 assert str(response.context["active"]) == "first_user" def test_send_message_view(self): message_count = Message.objects.count() - request = self.client.post(reverse("messager:send_message"), - {"to": "second_user", - "message": "A new short message"}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + request = self.client.post( + reverse("messager:send_message"), + {"to": "second_user", "message": "A new short message"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert request.status_code == 200 new_msm_count = Message.objects.count() assert new_msm_count == message_count + 1 def test_wrong_requests_send_message(self): - get_request = self.client.get(reverse("messager:send_message"), - {"to": "second_user", - "message": "A new short message"}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") - no_ajax_request = self.client.get(reverse("messager:send_message"), - {"to": "second_user", - "message": "A new short message"}) - same_user_request = self.client.post(reverse("messager:send_message"), - {"to": "first_user", - "message": "A new short message"}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") - no_lenght_request = self.client.post(reverse("messager:send_message"), - {"to": "second_user", - "message": ""}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + get_request = self.client.get( + reverse("messager:send_message"), + {"to": "second_user", "message": "A new short message"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + no_ajax_request = self.client.get( + reverse("messager:send_message"), + {"to": "second_user", "message": "A new short message"}, + ) + same_user_request = self.client.post( + reverse("messager:send_message"), + {"to": "first_user", "message": "A new short message"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + no_lenght_request = self.client.post( + reverse("messager:send_message"), + {"to": "second_user", "message": ""}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert get_request.status_code == 405 assert no_ajax_request.status_code == 400 assert same_user_request.status_code == 200 assert no_lenght_request.status_code == 200 def test_message_reception_view(self): - request = self.client.get(reverse("messager:receive_message"), - {"message_id": self.third_message.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + request = self.client.get( + reverse("messager:receive_message"), + {"message_id": self.third_message.uuid_id}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert b"An answer message." in request.content def test_wrong_request_recieve_message_view(self): - request = self.client.post(reverse("messager:receive_message"), - {"message_id": self.third_message.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + request = self.client.post( + reverse("messager:receive_message"), + {"message_id": self.third_message.uuid_id}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert request.status_code == 405 diff --git a/bootcamp/messager/urls.py b/bootcamp/messager/urls.py index 3c76f6225..5dcb79605 100755 --- a/bootcamp/messager/urls.py +++ b/bootcamp/messager/urls.py @@ -2,12 +2,14 @@ from bootcamp.messager import views -app_name = 'messager' +app_name = "messager" urlpatterns = [ - url(r'^$', views.MessagesListView.as_view(), name='messages_list'), - url(r'^send-message/$', views.send_message, name='send_message'), - url(r'^receive-message/$', - views.receive_message, name='receive_message'), - url(r'^(?P[\w.@+-]+)/$', views.ConversationListView.as_view(), - name='conversation_detail'), + url(r"^$", views.MessagesListView.as_view(), name="messages_list"), + url(r"^send-message/$", views.send_message, name="send_message"), + url(r"^receive-message/$", views.receive_message, name="receive_message"), + url( + r"^(?P[\w.@+-]+)/$", + views.ConversationListView.as_view(), + name="conversation_detail", + ), ] diff --git a/bootcamp/messager/views.py b/bootcamp/messager/views.py index 7e64c7ffc..a07210ae0 100755 --- a/bootcamp/messager/views.py +++ b/bootcamp/messager/views.py @@ -6,6 +6,7 @@ from django.views.decorators.http import require_http_methods from django.views.generic import ListView + from bootcamp.messager.models import Message from bootcamp.helpers import ajax_required @@ -14,38 +15,41 @@ class MessagesListView(LoginRequiredMixin, ListView): """CBV to render the inbox, showing by default the most recent conversation as the active one. """ + model = Message paginate_by = 50 template_name = "messager/message_list.html" def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['users_list'] = get_user_model().objects.filter( - is_active=True).exclude( - username=self.request.user).order_by('username') + context["users_list"] = ( + get_user_model() + .objects.filter(is_active=True) + .exclude(username=self.request.user) + .order_by("username") + ) last_conversation = Message.objects.get_most_recent_conversation( self.request.user ) - context['active'] = last_conversation.username + context["active"] = last_conversation.username return context def get_queryset(self): - active_user = Message.objects.get_most_recent_conversation( - self.request.user) + active_user = Message.objects.get_most_recent_conversation(self.request.user) return Message.objects.get_conversation(active_user, self.request.user) class ConversationListView(MessagesListView): """CBV to render the inbox, showing an specific conversation with a given user, who requires to be active too.""" + def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['active'] = self.kwargs["username"] + context["active"] = self.kwargs["username"] return context def get_queryset(self): - active_user = get_user_model().objects.get( - username=self.kwargs["username"]) + active_user = get_user_model().objects.get(username=self.kwargs["username"]) return Message.objects.get_conversation(active_user, self.request.user) @@ -57,16 +61,15 @@ def send_message(request): and create the new message and return the new data to be attached to the conversation stream.""" sender = request.user - recipient_username = request.POST.get('to') + recipient_username = request.POST.get("to") recipient = get_user_model().objects.get(username=recipient_username) - message = request.POST.get('message') + message = request.POST.get("message") if len(message.strip()) == 0: return HttpResponse() if sender != recipient: msg = Message.send_message(sender, recipient, message) - return render(request, 'messager/single_message.html', - {'message': msg}) + return render(request, "messager/single_message.html", {"message": msg}) return HttpResponse() @@ -77,7 +80,10 @@ def send_message(request): def receive_message(request): """Simple AJAX functional view to return a rendered single message on the receiver side providing realtime connections.""" - message_id = request.GET.get('message_id') - message = Message.objects.get(pk=message_id) - return render(request, - 'messager/single_message.html', {'message': message}) + message_id = request.GET.get("message_id") + try: + message = Message.objects.get(pk=message_id) + except Message.DoesNotExist as e: + raise e + + return render(request, "messager/single_message.html", {"message": message}) diff --git a/bootcamp/news/admin.py b/bootcamp/news/admin.py index f6e609d36..2bfe2797f 100755 --- a/bootcamp/news/admin.py +++ b/bootcamp/news/admin.py @@ -4,5 +4,5 @@ @admin.register(News) class NewseAdmin(admin.ModelAdmin): - list_display = ('content', 'user', 'reply') - list_filter = ('timestamp', 'reply') + list_display = ("content", "user", "reply") + list_filter = ("timestamp", "reply") diff --git a/bootcamp/news/apps.py b/bootcamp/news/apps.py index 0086fbb18..b2a4c66ac 100755 --- a/bootcamp/news/apps.py +++ b/bootcamp/news/apps.py @@ -3,5 +3,5 @@ class NewsConfig(AppConfig): - name = 'bootcamp.news' + name = "bootcamp.news" verbose_name = _("News") diff --git a/bootcamp/news/migrations/0001_initial.py b/bootcamp/news/migrations/0001_initial.py index 7ae2f338a..fdee0f4a4 100755 --- a/bootcamp/news/migrations/0001_initial.py +++ b/bootcamp/news/migrations/0001_initial.py @@ -10,26 +10,59 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( - name='News', + name="News", fields=[ - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('content', models.TextField(max_length=280)), - ('reply', models.BooleanField(default=False, verbose_name='Is a reply?')), - ('liked', models.ManyToManyField(blank=True, related_name='liked_news', to=settings.AUTH_USER_MODEL)), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='thread', to='news.News')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='publisher', to=settings.AUTH_USER_MODEL)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("content", models.TextField(max_length=280)), + ( + "reply", + models.BooleanField(default=False, verbose_name="Is a reply?"), + ), + ( + "liked", + models.ManyToManyField( + blank=True, + related_name="liked_news", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="thread", + to="news.News", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="publisher", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'News', - 'verbose_name_plural': 'News', - 'ordering': ('-timestamp',), + "verbose_name": "News", + "verbose_name_plural": "News", + "ordering": ("-timestamp",), }, - ), + ) ] diff --git a/bootcamp/news/migrations/0002_auto_20200405_1227.py b/bootcamp/news/migrations/0002_auto_20200405_1227.py new file mode 100644 index 000000000..9cb20b084 --- /dev/null +++ b/bootcamp/news/migrations/0002_auto_20200405_1227.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.3 on 2020-04-05 12:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("news", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="news", + name="meta_description", + field=models.TextField(max_length=255, null=True), + ), + migrations.AddField( + model_name="news", + name="meta_image", + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name="news", + name="meta_title", + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name="news", + name="meta_type", + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name="news", + name="meta_url", + field=models.CharField(max_length=2048, null=True), + ), + ] diff --git a/bootcamp/news/models.py b/bootcamp/news/models.py index 0f08a0889..bccfef3bd 100755 --- a/bootcamp/news/models.py +++ b/bootcamp/news/models.py @@ -10,23 +10,34 @@ from channels.layers import get_channel_layer from bootcamp.notifications.models import Notification, notification_handler +from bootcamp.helpers import fetch_metadata class News(models.Model): """News model to contain small information snippets in the same manner as Twitter does.""" + user = models.ForeignKey( - settings.AUTH_USER_MODEL, null=True, related_name="publisher", - on_delete=models.SET_NULL) - parent = models.ForeignKey("self", blank=True, - null=True, on_delete=models.CASCADE, related_name="thread") + settings.AUTH_USER_MODEL, + null=True, + related_name="publisher", + on_delete=models.SET_NULL, + ) + parent = models.ForeignKey( + "self", blank=True, null=True, on_delete=models.CASCADE, related_name="thread" + ) timestamp = models.DateTimeField(auto_now_add=True) - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) content = models.TextField(max_length=280) - liked = models.ManyToManyField(settings.AUTH_USER_MODEL, - blank=True, related_name="liked_news") + liked = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="liked_news" + ) reply = models.BooleanField(verbose_name=_("Is a reply?"), default=False) + meta_url = models.CharField(max_length=2048, null=True) + meta_type = models.CharField(max_length=255, null=True) + meta_title = models.CharField(max_length=255, null=True) + meta_description = models.TextField(max_length=255, null=True) + meta_image = models.CharField(max_length=255, null=True) class Meta: verbose_name = _("News") @@ -37,16 +48,24 @@ def __str__(self): return str(self.content) def save(self, *args, **kwargs): + # extract metada from content url + data = fetch_metadata(self.content) + if data: + self.meta_url = data.get("url") + self.meta_type = data.get("type", "website") + self.meta_title = data.get("title") + self.meta_description = data.get("description") + self.meta_image = data.get("image") + super().save(*args, **kwargs) if not self.reply: channel_layer = get_channel_layer() payload = { - "type": "receive", - "key": "additional_news", - "actor_name": self.user.username - - } - async_to_sync(channel_layer.group_send)('notifications', payload) + "type": "receive", + "key": "additional_news", + "actor_name": self.user.username, + } + async_to_sync(channel_layer.group_send)("notifications", payload) def get_absolute_url(self): return reverse("news:detail", kwargs={"uuid_id": self.uuid}) @@ -57,10 +76,14 @@ def switch_like(self, user): else: self.liked.add(user) - notification_handler(user, self.user, - Notification.LIKED, action_object=self, - id_value=str(self.uuid_id), - key='social_update') + notification_handler( + user, + self.user, + Notification.LIKED, + action_object=self, + id_value=str(self.uuid_id), + key="social_update", + ) def get_parent(self): if self.parent: @@ -80,14 +103,16 @@ def reply_this(self, user, text): """ parent = self.get_parent() reply_news = News.objects.create( - user=user, - content=text, - reply=True, - parent=parent + user=user, content=text, reply=True, parent=parent ) notification_handler( - user, parent.user, Notification.REPLY, action_object=reply_news, - id_value=str(parent.uuid_id), key='social_update') + user, + parent.user, + Notification.REPLY, + action_object=reply_news, + id_value=str(parent.uuid_id), + key="social_update", + ) def get_thread(self): parent = self.get_parent() diff --git a/bootcamp/news/schema.py b/bootcamp/news/schema.py index 1d1151815..51ea37e41 100755 --- a/bootcamp/news/schema.py +++ b/bootcamp/news/schema.py @@ -8,6 +8,7 @@ class NewsType(DjangoObjectType): """DjangoObjectType to acces the News model.""" + count_thread = graphene.Int() count_likers = graphene.Int() @@ -18,12 +19,19 @@ def resolve_count_thread(self, info, **kwargs): return self.get_thread().count() def resolve_count_likers(self, info, **kwargs): - return self.liked_news.count() + return self.get_likers().count() + + def resolve_get_thread(self, info, **kwargs): + return self.get_thread() + + def resolve_get_likers(self, info, **kwargs): + return self.get_likers() class NewsPaginatedType(graphene.ObjectType): """A paginated type generic object to provide pagination to the news graph.""" + page = graphene.Int() pages = graphene.Int() has_next = graphene.Boolean() @@ -47,9 +55,26 @@ def resolve_paginated_news(self, info, page): return paginate_data(qs, page_size, page, NewsPaginatedType) def resolve_news(self, info, **kwargs): - uuid_id = kwargs.get('uuid_id') + uuid_id = kwargs.get("uuid_id") if uuid_id is not None: return News.objects.get(uuid_id=uuid_id) return None + + +class NewsMutation(graphene.Mutation): + """Mutation to create news objects on a efective way.""" + + class Arguments: + content = graphene.String() + user = graphene.ID() + parent = graphene.ID() + + content = graphene.String() + user = graphene.ID() + parent = graphene.ID() + news = graphene.Field(lambda: News) + + def mutate(self, **kwargs): + print(kwargs) diff --git a/bootcamp/news/templatetags/__init__.py b/bootcamp/news/templatetags/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/bootcamp/news/templatetags/urlize_target_blank.py b/bootcamp/news/templatetags/urlize_target_blank.py new file mode 100755 index 000000000..5463a845f --- /dev/null +++ b/bootcamp/news/templatetags/urlize_target_blank.py @@ -0,0 +1,12 @@ +from django import template +from django.template.defaultfilters import stringfilter +from django.utils.safestring import mark_safe +from django.utils.html import urlize as urlize_impl + +register = template.Library() + + +@register.filter(is_safe=True, needs_autoescape=True) +@stringfilter +def urlize_target_blank(value, autoescape=None): + return value.replace("[-\w]+)/$', - views.NewsDeleteView.as_view(), name='delete_news'), - url(r'^post-news/$', views.post_news, name='post_news'), - url(r'^like/$', views.like, name='like_post'), - url(r'^get-thread/$', views.get_thread, name='get_thread'), - url(r'^post-comment/$', views.post_comment, name='post_comments'), - url(r'^update-interactions/$', views.update_interactions, name='update_interactions'), + url(r"^$", views.NewsListView.as_view(), name="list"), + url( + r"^delete/(?P[-\w]+)/$", views.NewsDeleteView.as_view(), name="delete_news" + ), + url(r"^post-news/$", views.post_news, name="post_news"), + url(r"^like/$", views.like, name="like_post"), + url(r"^get-thread/$", views.get_thread, name="get_thread"), + url(r"^post-comment/$", views.post_comment, name="post_comments"), + url( + r"^update-interactions/$", views.update_interactions, name="update_interactions" + ), ] diff --git a/bootcamp/news/views.py b/bootcamp/news/views.py index a5babc798..737492549 100755 --- a/bootcamp/news/views.py +++ b/bootcamp/news/views.py @@ -13,6 +13,7 @@ class NewsListView(LoginRequiredMixin, ListView): """A really simple ListView, with some JS magic on the UI.""" + model = News paginate_by = 15 @@ -23,6 +24,7 @@ def get_queryset(self, **kwargs): class NewsDeleteView(LoginRequiredMixin, AuthorRequiredMixin, DeleteView): """Implementation of the DeleteView overriding the delete method to allow a no-redirect response to use with AJAX call.""" + model = News success_url = reverse_lazy("news:list") @@ -34,25 +36,20 @@ def post_news(request): """A function view to implement the post functionality with AJAX allowing to create News instances as parent ones.""" user = request.user - post = request.POST['post'] + post = request.POST["post"] post = post.strip() - if len(post) > 0 and len(post) <= 280: - posted = News.objects.create( - user=user, - content=post, - ) + if 0 < len(post) <= 280: + posted = News.objects.create(user=user, content=post) html = render_to_string( - 'news/news_single.html', - { - 'news': posted, - 'request': request - }) + "news/news_single.html", {"news": posted, "request": request} + ) return HttpResponse(html) else: - lenght = len(post) - 280 + length = len(post) - 280 return HttpResponseBadRequest( - content=_(f'Text is {lenght} characters longer than accepted.')) + content=_(f"Text is {length} characters longer than accepted.") + ) @login_required @@ -61,7 +58,7 @@ def post_news(request): def like(request): """Function view to receive AJAX, returns the count of likes a given news has recieved.""" - news_id = request.POST['news'] + news_id = request.POST["news"] news = News.objects.get(pk=news_id) user = request.user news.switch_like(user) @@ -73,16 +70,13 @@ def like(request): @require_http_methods(["GET"]) def get_thread(request): """Returns a list of news with the given news as parent.""" - news_id = request.GET['news'] + news_id = request.GET["news"] news = News.objects.get(pk=news_id) news_html = render_to_string("news/news_single.html", {"news": news}) thread_html = render_to_string( - "news/news_thread.html", {"thread": news.get_thread()}) - return JsonResponse({ - "uuid": news_id, - "news": news_html, - "thread": thread_html, - }) + "news/news_thread.html", {"thread": news.get_thread(), "request": request} + ) + return JsonResponse({"uuid": news_id, "news": news_html, "thread": thread_html}) @login_required @@ -93,13 +87,13 @@ def post_comment(request): News instances who happens to be the children and commenters of the root post.""" user = request.user - post = request.POST['reply'] - par = request.POST['parent'] + post = request.POST["reply"] + par = request.POST["parent"] parent = News.objects.get(pk=par) post = post.strip() if post: parent.reply_this(user, post) - return JsonResponse({'comments': parent.count_thread()}) + return JsonResponse({"comments": parent.count_thread()}) else: return HttpResponseBadRequest() @@ -109,7 +103,7 @@ def post_comment(request): @ajax_required @require_http_methods(["POST"]) def update_interactions(request): - data_point = request.POST['id_value'] + data_point = request.POST["id_value"] news = News.objects.get(pk=data_point) - data = {'likes': news.count_likers(), 'comments': news.count_thread()} + data = {"likes": news.count_likers(), "comments": news.count_thread()} return JsonResponse(data) diff --git a/bootcamp/notifications/admin.py b/bootcamp/notifications/admin.py index be7fa0b27..ce9e0f7fd 100755 --- a/bootcamp/notifications/admin.py +++ b/bootcamp/notifications/admin.py @@ -4,5 +4,5 @@ @admin.register(Notification) class NotificationAdmin(admin.ModelAdmin): - list_display = ('recipient', 'actor', 'verb', 'unread', ) - list_filter = ('recipient', 'unread', ) + list_display = ("recipient", "actor", "verb", "unread") + list_filter = ("recipient", "unread") diff --git a/bootcamp/notifications/apps.py b/bootcamp/notifications/apps.py index 394767f2c..1dd04c24a 100755 --- a/bootcamp/notifications/apps.py +++ b/bootcamp/notifications/apps.py @@ -3,5 +3,5 @@ class NotificationsConfig(AppConfig): - name = 'bootcamp.notifications' - verbose_name = _('Notifications') + name = "bootcamp.notifications" + verbose_name = _("Notifications") diff --git a/bootcamp/notifications/consumers.py b/bootcamp/notifications/consumers.py index f27912e31..77b257019 100755 --- a/bootcamp/notifications/consumers.py +++ b/bootcamp/notifications/consumers.py @@ -7,6 +7,7 @@ class NotificationsConsumer(AsyncWebsocketConsumer): """Consumer to manage WebSocket connections for the Notification app, called when the websocket is handshaking as part of initial connection. """ + async def connect(self): """Consumer Connect implementation, to validate user status and prevent non authenticated user to take advante from the connection.""" @@ -16,15 +17,13 @@ async def connect(self): else: # Accept the connection - await self.channel_layer.group_add( - 'notifications', self.channel_name) + await self.channel_layer.group_add("notifications", self.channel_name) await self.accept() async def disconnect(self, close_code): """Consumer implementation to leave behind the group at the moment the closes the connection.""" - await self.channel_layer.group_discard( - 'notifications', self.channel_name) + await self.channel_layer.group_discard("notifications", self.channel_name) async def receive(self, text_data): """Receive method implementation to redirect any new message received diff --git a/bootcamp/notifications/migrations/0001_initial.py b/bootcamp/notifications/migrations/0001_initial.py index c98505d24..5af68e2dd 100755 --- a/bootcamp/notifications/migrations/0001_initial.py +++ b/bootcamp/notifications/migrations/0001_initial.py @@ -11,28 +11,82 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Notification', + name="Notification", fields=[ - ('unread', models.BooleanField(db_index=True, default=True)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('slug', models.SlugField(blank=True, max_length=210, null=True)), - ('verb', models.CharField(choices=[('L', 'liked'), ('C', 'commented'), ('F', 'cavorited'), ('A', 'answered'), ('W', 'accepted'), ('E', 'edited'), ('K', 'also commented'), ('I', 'logged in'), ('O', 'logged out'), ('V', 'voted on'), ('S', 'shared'), ('U', 'created an account'), ('R', 'replied to')], max_length=1)), - ('action_object_object_id', models.CharField(blank=True, max_length=50, null=True)), - ('action_object_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notify_action_object', to='contenttypes.ContentType')), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notify_actor', to=settings.AUTH_USER_MODEL)), - ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ("unread", models.BooleanField(db_index=True, default=True)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("slug", models.SlugField(blank=True, max_length=210, null=True)), + ( + "verb", + models.CharField( + choices=[ + ("L", "liked"), + ("C", "commented"), + ("F", "cavorited"), + ("A", "answered"), + ("W", "accepted"), + ("E", "edited"), + ("K", "also commented"), + ("I", "logged in"), + ("O", "logged out"), + ("V", "voted on"), + ("S", "shared"), + ("U", "created an account"), + ("R", "replied to"), + ], + max_length=1, + ), + ), + ( + "action_object_object_id", + models.CharField(blank=True, max_length=50, null=True), + ), + ( + "action_object_content_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notify_action_object", + to="contenttypes.ContentType", + ), + ), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notify_actor", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Notification', - 'verbose_name_plural': 'Notifications', - 'ordering': ('-timestamp',), + "verbose_name": "Notification", + "verbose_name_plural": "Notifications", + "ordering": ("-timestamp",), }, - ), + ) ] diff --git a/bootcamp/notifications/models.py b/bootcamp/notifications/models.py index fbb198767..76229cc34 100755 --- a/bootcamp/notifications/models.py +++ b/bootcamp/notifications/models.py @@ -4,7 +4,6 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.core import serializers from django.db import models from django.utils.translation import ugettext_lazy as _ @@ -46,23 +45,9 @@ def mark_all_as_unread(self, recipient=None): return qs.update(unread=True) - def serialize_latest_notifications(self, recipient=None): - """Returns a serialized version of the most recent unread elements in - the queryset""" - qs = self.unread()[:5] - if recipient: - qs = qs.filter(recipient=recipient)[:5] - - notification_dic = serializers.serialize("json", qs) - return notification_dic - - def get_most_recent(self, recipient=None): + def get_most_recent(self): """Returns the most recent unread elements in the queryset""" - qs = self.unread()[:5] - if recipient: - qs = qs.filter(recipient=recipient)[:5] - - return qs + return self.unread()[:5] class Notification(models.Model): @@ -83,52 +68,60 @@ class Notification(models.Model): <1 minute ago>
<2 hours ago> """ - LIKED = 'L' - COMMENTED = 'C' - FAVORITED = 'F' - ANSWERED = 'A' - ACCEPTED_ANSWER = 'W' - EDITED_ARTICLE = 'E' - ALSO_COMMENTED = 'K' - LOGGED_IN = 'I' - LOGGED_OUT = 'O' - VOTED = 'V' - SHARED = 'S' - SIGNUP = 'U' - REPLY = 'R' + + LIKED = "L" + COMMENTED = "C" + FAVORITED = "F" + ANSWERED = "A" + ACCEPTED_ANSWER = "W" + EDITED_ARTICLE = "E" + ALSO_COMMENTED = "K" + LOGGED_IN = "I" + LOGGED_OUT = "O" + VOTED = "V" + SHARED = "S" + SIGNUP = "U" + REPLY = "R" NOTIFICATION_TYPES = ( - (LIKED, _('liked')), - (COMMENTED, _('commented')), - (FAVORITED, _('cavorited')), - (ANSWERED, _('answered')), - (ACCEPTED_ANSWER, _('accepted')), - (EDITED_ARTICLE, _('edited')), - (ALSO_COMMENTED, _('also commented')), - (LOGGED_IN, _('logged in')), - (LOGGED_OUT, _('logged out')), - (VOTED, _('voted on')), - (SHARED, _('shared')), - (SIGNUP, _('created an account')), - (REPLY, _('replied to')) - ) - actor = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name="notify_actor", - on_delete=models.CASCADE) - recipient = models.ForeignKey(settings.AUTH_USER_MODEL, blank=False, - related_name="notifications", on_delete=models.CASCADE) + (LIKED, _("liked")), + (COMMENTED, _("commented")), + (FAVORITED, _("cavorited")), + (ANSWERED, _("answered")), + (ACCEPTED_ANSWER, _("accepted")), + (EDITED_ARTICLE, _("edited")), + (ALSO_COMMENTED, _("also commented")), + (LOGGED_IN, _("logged in")), + (LOGGED_OUT, _("logged out")), + (VOTED, _("voted on")), + (SHARED, _("shared")), + (SIGNUP, _("created an account")), + (REPLY, _("replied to")), + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, related_name="notify_actor", on_delete=models.CASCADE + ) + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=False, + related_name="notifications", + on_delete=models.CASCADE, + ) unread = models.BooleanField(default=True, db_index=True) timestamp = models.DateTimeField(auto_now_add=True) - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) slug = models.SlugField(max_length=210, null=True, blank=True) verb = models.CharField(max_length=1, choices=NOTIFICATION_TYPES) - action_object_content_type = models.ForeignKey(ContentType, - blank=True, null=True, related_name="notify_action_object", - on_delete=models.CASCADE) - action_object_object_id = models.CharField( - max_length=50, blank=True, null=True) + action_object_content_type = models.ForeignKey( + ContentType, + blank=True, + null=True, + related_name="notify_action_object", + on_delete=models.CASCADE, + ) + action_object_object_id = models.CharField(max_length=50, blank=True, null=True) action_object = GenericForeignKey( - "action_object_content_type", "action_object_object_id") + "action_object_content_type", "action_object_object_id" + ) objects = NotificationQuerySet.as_manager() class Meta: @@ -138,14 +131,17 @@ class Meta: def __str__(self): if self.action_object: - return f'{self.actor} {self.get_verb_display()} {self.action_object} {self.time_since()} ago' + return f"{self.actor} {self.get_verb_display()} {self.action_object} {self.time_since()} ago" - return f'{self.actor} {self.get_verb_display()} {self.time_since()} ago' + return f"{self.actor} {self.get_verb_display()} {self.time_since()} ago" def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(f'{self.recipient} {self.uuid_id} {self.verb}', - to_lower=True, max_length=200) + self.slug = slugify( + f"{self.recipient} {self.uuid_id} {self.verb}", + lowercase=True, + max_length=200, + ) super().save(*args, **kwargs) @@ -162,32 +158,32 @@ def get_icon(self): """Model method to validate notification type and return the closest icon to the verb. """ - if self.verb == 'C' or self.verb == 'A' or self.verb == 'K': - return 'fa-comment' + if self.verb == "C" or self.verb == "A" or self.verb == "K": + return "fa-comment" - elif self.verb == 'I' or self.verb == 'U' or self.verb == 'O': - return 'fa-users' + elif self.verb == "I" or self.verb == "U" or self.verb == "O": + return "fa-users" - elif self.verb == 'L': - return 'fa-heart' + elif self.verb == "L": + return "fa-heart" - elif self.verb == 'F': - return 'fa-star' + elif self.verb == "F": + return "fa-star" - elif self.verb == 'W': - return 'fa-check-circle' + elif self.verb == "W": + return "fa-check-circle" - elif self.verb == 'E': - return 'fa-pencil' + elif self.verb == "E": + return "fa-pencil" - elif self.verb == 'V': - return 'fa-plus' + elif self.verb == "V": + return "fa-plus" - elif self.verb == 'S': - return 'fa-share-alt' + elif self.verb == "S": + return "fa-share-alt" - elif self.verb == 'R': - return 'fa-reply' + elif self.verb == "R": + return "fa-reply" def mark_as_read(self): if self.unread: @@ -214,16 +210,16 @@ def notification_handler(actor, recipient, verb, **kwargs): :param key: String defining what kind of notification is going to be created. :param id_value: UUID value assigned to a specific element in the DOM. """ - key = kwargs.pop('key', 'notification') - id_value = kwargs.pop('id_value', None) - if recipient == 'global': + key = kwargs.pop("key", "notification") + id_value = kwargs.pop("id_value", None) + if recipient == "global": users = get_user_model().objects.all().exclude(username=actor.username) for user in users: Notification.objects.create( actor=actor, recipient=user, verb=verb, - action_object=kwargs.pop('action_object', None) + action_object=kwargs.pop("action_object", None), ) notification_broadcast(actor, key) @@ -233,7 +229,7 @@ def notification_handler(actor, recipient, verb, **kwargs): actor=actor, recipient=get_user_model().objects.get(username=user), verb=verb, - action_object=kwargs.pop('action_object', None) + action_object=kwargs.pop("action_object", None), ) elif isinstance(recipient, get_user_model()): @@ -241,10 +237,11 @@ def notification_handler(actor, recipient, verb, **kwargs): actor=actor, recipient=recipient, verb=verb, - action_object=kwargs.pop('action_object', None) + action_object=kwargs.pop("action_object", None), ) notification_broadcast( - actor, key, id_value=id_value, recipient=recipient.username) + actor, key, id_value=id_value, recipient=recipient.username + ) else: pass @@ -264,13 +261,13 @@ def notification_broadcast(actor, key, **kwargs): notified. """ channel_layer = get_channel_layer() - id_value = kwargs.pop('id_value', None) - recipient = kwargs.pop('recipient', None) + id_value = kwargs.pop("id_value", None) + recipient = kwargs.pop("recipient", None) payload = { - 'type': 'receive', - 'key': key, - 'actor_name': actor.username, - 'id_value': id_value, - 'recipient': recipient - } - async_to_sync(channel_layer.group_send)('notifications', payload) + "type": "receive", + "key": key, + "actor_name": actor.username, + "id_value": id_value, + "recipient": recipient, + } + async_to_sync(channel_layer.group_send)("notifications", payload) diff --git a/bootcamp/notifications/tests/test_models.py b/bootcamp/notifications/tests/test_models.py index 9ea96d226..57d914148 100755 --- a/bootcamp/notifications/tests/test_models.py +++ b/bootcamp/notifications/tests/test_models.py @@ -8,32 +8,46 @@ class NotificationsModelsTest(TestCase): def setUp(self): self.user = self.make_user("test_user") self.other_user = self.make_user("other_test_user") + self.first_news = News.objects.create( + user=self.user, content="This is a short content." + ) + self.second_news = News.objects.create( + user=self.other_user, + content="This is an answer to the first news.", + reply=True, + parent=self.first_news, + ) self.first_notification = Notification.objects.create( - actor=self.user, - recipient=self.other_user, - verb="L" - ) + actor=self.user, recipient=self.other_user, verb="L" + ) self.second_notification = Notification.objects.create( - actor=self.user, - recipient=self.other_user, - verb="C" - ) + actor=self.user, recipient=self.other_user, verb="C" + ) self.third_notification = Notification.objects.create( - actor=self.other_user, - recipient=self.user, - verb="A" - ) + actor=self.other_user, recipient=self.user, verb="A" + ) + self.fourth_notification = Notification.objects.create( + actor=self.other_user, + recipient=self.user, + action_object=self.first_news, + verb="A", + ) def test_return_values(self): assert isinstance(self.first_notification, Notification) assert isinstance(self.second_notification, Notification) assert isinstance(self.third_notification, Notification) + assert isinstance(self.fourth_notification, Notification) assert str(self.first_notification) == "test_user liked 0 minutes ago" assert str(self.second_notification) == "test_user commented 0 minutes ago" assert str(self.third_notification) == "other_test_user answered 0 minutes ago" + assert ( + str(self.fourth_notification) + == "other_test_user answered This is a short content. 0 minutes ago" + ) def test_return_unread(self): - assert Notification.objects.unread().count() == 3 + assert Notification.objects.unread().count() == 4 assert self.first_notification in Notification.objects.unread() def test_mark_as_read_and_return(self): @@ -45,23 +59,20 @@ def test_mark_as_read_and_return(self): def test_mark_all_as_read(self): Notification.objects.mark_all_as_read() - assert Notification.objects.read().count() == 3 + assert Notification.objects.read().count() == 4 Notification.objects.mark_all_as_unread(self.other_user) - assert Notification.objects.read().count() == 1 + assert Notification.objects.read().count() == 2 Notification.objects.mark_all_as_unread() - assert Notification.objects.unread().count() == 3 + assert Notification.objects.unread().count() == 4 Notification.objects.mark_all_as_read(self.other_user) assert Notification.objects.read().count() == 2 def test_get_most_recent(self): - assert Notification.objects.get_most_recent().count() == 3 + assert Notification.objects.get_most_recent().count() == 4 def test_single_notification(self): Notification.objects.mark_all_as_read() - obj = News.objects.create( - user=self.user, - content="This is a short content." - ) + obj = News.objects.create(user=self.user, content="This is a short content.") notification_handler(self.user, self.other_user, "C", action_object=obj) assert Notification.objects.unread().count() == 1 @@ -74,3 +85,73 @@ def test_list_notification(self): Notification.objects.mark_all_as_read() notification_handler(self.user, [self.user, self.other_user], "C") assert Notification.objects.unread().count() == 2 + + def test_icon_comment(self): + notification_one = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="C" + ) + notification_two = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="A" + ) + notification_three = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="K" + ) + assert notification_one.get_icon() == "fa-comment" + assert notification_two.get_icon() == "fa-comment" + assert notification_three.get_icon() == "fa-comment" + + def test_icon_users(self): + notification_one = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="I" + ) + notification_two = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="U" + ) + notification_three = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="O" + ) + assert notification_one.get_icon() == "fa-users" + assert notification_two.get_icon() == "fa-users" + assert notification_three.get_icon() == "fa-users" + + def test_icon_hearth(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="L" + ) + assert notification.get_icon() == "fa-heart" + + def test_icon_star(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="F" + ) + assert notification.get_icon() == "fa-star" + + def test_icon_check_circle(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="W" + ) + assert notification.get_icon() == "fa-check-circle" + + def test_icon_pencil(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="E" + ) + assert notification.get_icon() == "fa-pencil" + + def test_icon_plus(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="V" + ) + assert notification.get_icon() == "fa-plus" + + def test_icon_share(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="S" + ) + assert notification.get_icon() == "fa-share-alt" + + def test_icon_reply(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="R" + ) + assert notification.get_icon() == "fa-reply" diff --git a/bootcamp/notifications/tests/test_views.py b/bootcamp/notifications/tests/test_views.py index f96ac1fd5..89efcda8c 100755 --- a/bootcamp/notifications/tests/test_views.py +++ b/bootcamp/notifications/tests/test_views.py @@ -15,20 +15,14 @@ def setUp(self): self.client.login(username="first_user", password="password") self.other_client.login(username="second_user", password="password") self.first_notification = Notification.objects.create( - actor=self.user, - recipient=self.other_user, - verb="L" - ) + actor=self.user, recipient=self.other_user, verb="L" + ) self.second_notification = Notification.objects.create( - actor=self.user, - recipient=self.other_user, - verb="C" - ) + actor=self.user, recipient=self.other_user, verb="C" + ) self.third_notification = Notification.objects.create( - actor=self.other_user, - recipient=self.user, - verb="A" - ) + actor=self.other_user, recipient=self.user, verb="A" + ) def test_notification_list(self): response = self.client.get(reverse("notifications:unread")) @@ -37,12 +31,16 @@ def test_notification_list(self): def test_mark_all_as_read(self): response = self.client.get(reverse("notifications:mark_all_read"), follow=True) - assert '/notifications/' in str(response.context["request"]) + assert "/notifications/" in str(response.context["request"]) assert Notification.objects.unread().count() == 2 def test_mark_as_read(self): response = self.client.get( - reverse("notifications:mark_as_read", kwargs={"slug": self.first_notification.slug})) + reverse( + "notifications:mark_as_read", + kwargs={"slug": self.first_notification.slug}, + ) + ) assert response.status_code == 302 assert Notification.objects.unread().count() == 2 diff --git a/bootcamp/notifications/urls.py b/bootcamp/notifications/urls.py index 813cce56f..3593fb82e 100755 --- a/bootcamp/notifications/urls.py +++ b/bootcamp/notifications/urls.py @@ -2,10 +2,14 @@ from bootcamp.notifications import views -app_name = 'notifications' +app_name = "notifications" urlpatterns = [ - url(r'^$', views.NotificationUnreadListView.as_view(), name='unread'), - url(r'^mark-as-read/(?P[-\w]+)/$', views.mark_as_read, name='mark_as_read'), - url(r'^mark-all-as-read/$', views.mark_all_as_read, name='mark_all_read'), - url(r'^latest-notifications/$', views.get_latest_notifications, name='latest_notifications'), + url(r"^$", views.NotificationUnreadListView.as_view(), name="unread"), + url(r"^mark-as-read/(?P[-\w]+)/$", views.mark_as_read, name="mark_as_read"), + url(r"^mark-all-as-read/$", views.mark_all_as_read, name="mark_all_read"), + url( + r"^latest-notifications/$", + views.get_latest_notifications, + name="latest_notifications", + ), ] diff --git a/bootcamp/notifications/views.py b/bootcamp/notifications/views.py index 9392081b0..7fcd3ef4b 100755 --- a/bootcamp/notifications/views.py +++ b/bootcamp/notifications/views.py @@ -11,9 +11,10 @@ class NotificationUnreadListView(LoginRequiredMixin, ListView): """Basic ListView implementation to show the unread notifications for the actual user""" + model = Notification - context_object_name = 'notification_list' - template_name = 'notifications/notification_list.html' + context_object_name = "notification_list" + template_name = "notifications/notification_list.html" def get_queryset(self, **kwargs): return self.request.user.notifications.unread() @@ -24,15 +25,17 @@ def mark_all_as_read(request): """View to call the model method which marks as read all the notifications directed to the actual user.""" request.user.notifications.mark_all_as_read() - _next = request.GET.get('next') + _next = request.GET.get("next") messages.add_message( - request, messages.SUCCESS, - _(f'All notifications to {request.user.username} have been marked as read.')) + request, + messages.SUCCESS, + _(f"All notifications to {request.user.username} have been marked as read."), + ) if _next: return redirect(_next) - return redirect('notifications:unread') + return redirect("notifications:unread") @login_required @@ -44,19 +47,21 @@ def mark_as_read(request, slug=None): notification.mark_as_read() messages.add_message( - request, messages.SUCCESS, - _(f'The notification {notification.slug} has been marked as read.')) - _next = request.GET.get('next') + request, + messages.SUCCESS, + _(f"The notification {notification.slug} has been marked as read."), + ) + _next = request.GET.get("next") if _next: return redirect(_next) - return redirect('notifications:unread') + return redirect("notifications:unread") @login_required def get_latest_notifications(request): notifications = request.user.notifications.get_most_recent() - return render(request, - 'notifications/most_recent.html', - {'notifications': notifications}) + return render( + request, "notifications/most_recent.html", {"notifications": notifications} + ) diff --git a/bootcamp/qa/apps.py b/bootcamp/qa/apps.py index 17909cc29..7a2617150 100755 --- a/bootcamp/qa/apps.py +++ b/bootcamp/qa/apps.py @@ -3,5 +3,5 @@ class QaConfig(AppConfig): - name = 'bootcamp.qa' - verbose_name = _('Q&A') + name = "bootcamp.qa" + verbose_name = _("Q&A") diff --git a/bootcamp/qa/migrations/0001_initial.py b/bootcamp/qa/migrations/0001_initial.py index 16ebf7c98..34849feea 100755 --- a/bootcamp/qa/migrations/0001_initial.py +++ b/bootcamp/qa/migrations/0001_initial.py @@ -13,78 +13,137 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('taggit', '0002_auto_20150616_2121'), + ("taggit", "0002_auto_20150616_2121"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ migrations.CreateModel( - name='Answer', + name="Answer", fields=[ - ('content', markdownx.models.MarkdownxField()), - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('total_votes', models.IntegerField(default=0)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('is_answer', models.BooleanField(default=False)), + ("content", markdownx.models.MarkdownxField()), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("total_votes", models.IntegerField(default=0)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("is_answer", models.BooleanField(default=False)), ], options={ - 'verbose_name': 'Answer', - 'verbose_name_plural': 'Answers', - 'ordering': ['-is_answer', '-timestamp'], + "verbose_name": "Answer", + "verbose_name_plural": "Answers", + "ordering": ["-is_answer", "-timestamp"], }, ), migrations.CreateModel( - name='Question', + name="Question", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=200, unique=True)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('slug', models.SlugField(blank=True, max_length=80, null=True)), - ('status', models.CharField(choices=[('O', 'Open'), ('C', 'Closed'), ('D', 'Draft')], default='D', max_length=1)), - ('content', markdownx.models.MarkdownxField()), - ('has_answer', models.BooleanField(default=False)), - ('total_votes', models.IntegerField(default=0)), - ('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=200, unique=True)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("slug", models.SlugField(blank=True, max_length=80, null=True)), + ( + "status", + models.CharField( + choices=[("O", "Open"), ("C", "Closed"), ("D", "Draft")], + default="D", + max_length=1, + ), + ), + ("content", markdownx.models.MarkdownxField()), + ("has_answer", models.BooleanField(default=False)), + ("total_votes", models.IntegerField(default=0)), + ( + "tags", + taggit.managers.TaggableManager( + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Question', - 'verbose_name_plural': 'Questions', - 'ordering': ['-timestamp'], + "verbose_name": "Question", + "verbose_name_plural": "Questions", + "ordering": ["-timestamp"], }, ), migrations.CreateModel( - name='Vote', + name="Vote", fields=[ - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('value', models.BooleanField(default=True)), - ('object_id', models.CharField(blank=True, max_length=50, null=True)), - ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='votes_on', to='contenttypes.ContentType')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("value", models.BooleanField(default=True)), + ("object_id", models.CharField(blank=True, max_length=50, null=True)), + ( + "content_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="votes_on", + to="contenttypes.ContentType", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], - options={ - 'verbose_name': 'Vote', - 'verbose_name_plural': 'Votes', - }, + options={"verbose_name": "Vote", "verbose_name_plural": "Votes"}, ), migrations.AddField( - model_name='answer', - name='question', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='qa.Question'), + model_name="answer", + name="question", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="qa.Question" + ), ), migrations.AddField( - model_name='answer', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + model_name="answer", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), ), migrations.AlterUniqueTogether( - name='vote', - unique_together={('user', 'content_type', 'object_id')}, + name="vote", unique_together={("user", "content_type", "object_id")} ), migrations.AlterIndexTogether( - name='vote', - index_together={('content_type', 'object_id')}, + name="vote", index_together={("content_type", "object_id")} ), ] diff --git a/bootcamp/qa/models.py b/bootcamp/qa/models.py index b043d28a2..a59b89f4e 100755 --- a/bootcamp/qa/models.py +++ b/bootcamp/qa/models.py @@ -18,18 +18,20 @@ class Vote(models.Model): """Model class to host every vote, made with ContentType framework to allow a single model connected to Questions and Answers.""" - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) timestamp = models.DateTimeField(auto_now_add=True) value = models.BooleanField(default=True) - content_type = models.ForeignKey(ContentType, - blank=True, null=True, related_name="votes_on", on_delete=models.CASCADE) - object_id = models.CharField( - max_length=50, blank=True, null=True) - vote = GenericForeignKey( - "content_type", "object_id") + content_type = models.ForeignKey( + ContentType, + blank=True, + null=True, + related_name="votes_on", + on_delete=models.CASCADE, + ) + object_id = models.CharField(max_length=50, blank=True, null=True) + vote = GenericForeignKey("content_type", "object_id") class Meta: verbose_name = _("Vote") @@ -54,7 +56,7 @@ def get_unanswered(self): def get_counted_tags(self): """Returns a dict element with tags and its count to show on the UI.""" tag_dict = {} - query = self.all().annotate(tagged=Count('tags')).filter(tags__gt=0) + query = self.all().annotate(tagged=Count("tags")).filter(tags__gt=0) for obj in query: for tag in obj.tags.names(): if tag not in tag_dict: @@ -68,14 +70,11 @@ def get_counted_tags(self): class Question(models.Model): """Model class to contain every question in the forum.""" + OPEN = "O" CLOSED = "C" DRAFT = "D" - STATUS = ( - (OPEN, _("Open")), - (CLOSED, _("Closed")), - (DRAFT, _("Draft")), - ) + STATUS = ((OPEN, _("Open")), (CLOSED, _("Closed")), (DRAFT, _("Draft"))) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) title = models.CharField(max_length=200, unique=True, blank=False) timestamp = models.DateTimeField(auto_now_add=True) @@ -95,8 +94,9 @@ class Meta: def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(f"{self.title}-{self.id}", - to_lower=True, max_length=80) + self.slug = slugify( + f"{self.title}-{self.id}", lowercase=True, max_length=80 + ) super().save(*args, **kwargs) @@ -135,11 +135,11 @@ def get_markdown(self): class Answer(models.Model): """Model class to contain every answer in the forum and to link it to its respective question.""" + question = models.ForeignKey(Question, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) content = MarkdownxField() - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) total_votes = models.IntegerField(default=0) timestamp = models.DateTimeField(auto_now_add=True) is_answer = models.BooleanField(default=False) @@ -160,7 +160,9 @@ def count_votes(self): """Method to update the sum of the total votes. Uses this complex query to avoid race conditions at database level.""" dic = Counter(self.votes.values_list("value", flat=True)) - Answer.objects.filter(uuid_id=self.uuid_id).update(total_votes=dic[True] - dic[False]) + Answer.objects.filter(uuid_id=self.uuid_id).update( + total_votes=dic[True] - dic[False] + ) self.refresh_from_db() def get_upvoters(self): diff --git a/bootcamp/qa/tests/test_models.py b/bootcamp/qa/tests/test_models.py index 280c88a2a..5f8567788 100755 --- a/bootcamp/qa/tests/test_models.py +++ b/bootcamp/qa/tests/test_models.py @@ -8,10 +8,11 @@ def setUp(self): self.user = self.make_user("test_user") self.other_user = self.make_user("other_test_user") self.question_one = Question.objects.create( - user=self.user, title="This is a sample question", + user=self.user, + title="This is a sample question", content="This is a sample question content", - tags="test1, test2" ) + self.question_one.tags.add("test1", "test2") self.question_two = Question.objects.create( user=self.user, title="A Short Title", @@ -21,45 +22,49 @@ def setUp(self): know than nobody wants to publish a test, just a test; everybody always wants the real deal.""", has_answer=True, - tags="test1, test2" ) + self.question_two.tags.add("test1", "test2") self.answer = Answer.objects.create( user=self.user, question=self.question_two, content="A reaaaaally loooong content", - is_answer=True + is_answer=True, ) def test_can_vote_question(self): self.question_one.votes.update_or_create( - user=self.user, defaults={"value": True}, ) + user=self.user, defaults={"value": True} + ) self.question_one.votes.update_or_create( - user=self.other_user, defaults={"value": True}) + user=self.other_user, defaults={"value": True} + ) self.question_one.count_votes() assert self.question_one.total_votes == 2 def test_can_vote_answer(self): + self.answer.votes.update_or_create(user=self.user, defaults={"value": True}) self.answer.votes.update_or_create( - user=self.user, defaults={"value": True}, ) - self.answer.votes.update_or_create( - user=self.other_user, defaults={"value": True}, ) + user=self.other_user, defaults={"value": True} + ) self.answer.count_votes() assert self.answer.total_votes == 2 def test_get_question_voters(self): self.question_one.votes.update_or_create( - user=self.user, defaults={"value": True}, ) + user=self.user, defaults={"value": True} + ) self.question_one.votes.update_or_create( - user=self.other_user, defaults={"value": False}) + user=self.other_user, defaults={"value": False} + ) self.question_one.count_votes() assert self.user in self.question_one.get_upvoters() assert self.other_user in self.question_one.get_downvoters() def test_get_answern_voters(self): + self.answer.votes.update_or_create(user=self.user, defaults={"value": True}) self.answer.votes.update_or_create( - user=self.user, defaults={"value": True}, ) - self.answer.votes.update_or_create( - user=self.other_user, defaults={"value": False}) + user=self.other_user, defaults={"value": False} + ) self.answer.count_votes() assert self.user in self.answer.get_upvoters() assert self.other_user in self.answer.get_downvoters() @@ -83,6 +88,10 @@ def test_question_answer_count(self): def test_question_accepted_answer(self): assert self.question_two.get_accepted_answer() == self.answer + def test_get_popular_tags(self): + correct_dict = {"test1": 2, "test2": 2} + assert Question.objects.get_counted_tags() == correct_dict.items() + # Answer model tests def test_answer_return_value(self): assert str(self.answer) == "A reaaaaally loooong content" @@ -91,17 +100,17 @@ def test_answer_accept_method(self): answer_one = Answer.objects.create( user=self.user, question=self.question_one, - content="A reaaaaally loooonger content" + content="A reaaaaally loooonger content", ) answer_two = Answer.objects.create( user=self.user, question=self.question_one, - content="A reaaaaally even loooonger content" + content="A reaaaaally even loooonger content", ) answer_three = Answer.objects.create( user=self.user, question=self.question_one, - content="Even a reaaaaally loooonger content" + content="Even a reaaaaally loooonger content", ) self.assertFalse(answer_one.is_answer) self.assertFalse(answer_two.is_answer) diff --git a/bootcamp/qa/tests/test_views.py b/bootcamp/qa/tests/test_views.py index 720f22cf5..9e6e889d3 100755 --- a/bootcamp/qa/tests/test_views.py +++ b/bootcamp/qa/tests/test_views.py @@ -15,9 +15,10 @@ def setUp(self): self.client.login(username="first_user", password="password") self.other_client.login(username="second_user", password="password") self.question_one = Question.objects.create( - user=self.user, title="This is a sample question", + user=self.user, + title="This is a sample question", content="This is a sample question content", - tags="test1, test2" + tags="test1, test2", ) self.question_two = Question.objects.create( user=self.user, @@ -28,13 +29,13 @@ def setUp(self): know than nobody wants to publish a test, just a test; everybody always wants the real deal.""", has_answer=True, - tags="test1, test2" + tags="test1, test2", ) self.answer = Answer.objects.create( user=self.user, question=self.question_two, content="A reaaaaally loooong content", - is_answer=True + is_answer=True, ) def test_index_questions(self): @@ -44,11 +45,15 @@ def test_index_questions(self): def test_create_question_view(self): current_count = Question.objects.count() - response = self.client.post(reverse("qa:ask_question"), - {"title": "Not much of a title", - "content": "bablababla bablababla", - "status": "O", - "tags": "test, tag"}) + response = self.client.post( + reverse("qa:ask_question"), + { + "title": "Not much of a title", + "content": "bablababla bablababla", + "status": "O", + "tags": "test, tag", + }, + ) assert response.status_code == 302 new_question = Question.objects.first() assert new_question.title == "Not much of a title" @@ -67,9 +72,8 @@ def test_unanswered_questions(self): def test_answer_question(self): current_answer_count = Answer.objects.count() response = self.client.post( - reverse("qa:propose_answer", - kwargs={"question_id": self.question_one.id}), - {"content": "A reaaaaally loooong content"} + reverse("qa:propose_answer", kwargs={"question_id": self.question_one.id}), + {"content": "A reaaaaally loooong content"}, ) assert response.status_code == 302 assert Answer.objects.count() == current_answer_count + 1 @@ -78,33 +82,50 @@ def test_question_upvote(self): response_one = self.client.post( reverse("qa:question_vote"), {"value": "U", "question": self.question_one.id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response_one.status_code == 200 def test_question_downvote(self): response_one = self.client.post( reverse("qa:question_vote"), {"value": "D", "question": self.question_one.id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response_one.status_code == 200 def test_answer_upvote(self): response_one = self.client.post( reverse("qa:answer_vote"), {"value": "U", "answer": self.answer.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response_one.status_code == 200 def test_answer_downvote(self): response_one = self.client.post( reverse("qa:answer_vote"), {"value": "D", "answer": self.answer.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response_one.status_code == 200 def test_accept_answer(self): response_one = self.client.post( reverse("qa:accept_answer"), {"answer": self.answer.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + assert response_one.status_code == 200 + + def test_owner_in_context(self): + response_one = self.client.get( + reverse("qa:question_detail", kwargs={"pk": self.question_one.id}) + ) + response_two = self.other_client.get( + reverse("qa:question_detail", kwargs={"pk": self.question_two.id}) + ) assert response_one.status_code == 200 + assert response_two.status_code == 200 + assert response_one.context.get("is_question_owner") is True + assert response_two.context.get("is_question_owner") is False diff --git a/bootcamp/qa/urls.py b/bootcamp/qa/urls.py index 9067f7ecb..3b5d057b9 100755 --- a/bootcamp/qa/urls.py +++ b/bootcamp/qa/urls.py @@ -2,15 +2,23 @@ from bootcamp.qa import views -app_name = 'qa' +app_name = "qa" urlpatterns = [ - url(r'^$', views.QuestionListView.as_view(), name='index_noans'), - url(r'^answered/$', views.QuestionAnsListView.as_view(), name='index_ans'), - url(r'^indexed/$', views.QuestionsIndexListView.as_view(), name='index_all'), - url(r'^question-detail/(?P\d+)/$', views.QuestionDetailView.as_view(), name='question_detail'), - url(r'^ask-question/$', views.CreateQuestionView.as_view(), name='ask_question'), - url(r'^propose-answer/(?P\d+)/$', views.CreateAnswerView.as_view(), name='propose_answer'), - url(r'^question/vote/$', views.question_vote, name='question_vote'), - url(r'^answer/vote/$', views.answer_vote, name='answer_vote'), - url(r'^accept-answer/$', views.accept_answer, name='accept_answer'), + url(r"^$", views.QuestionListView.as_view(), name="index_noans"), + url(r"^answered/$", views.QuestionAnsListView.as_view(), name="index_ans"), + url(r"^indexed/$", views.QuestionsIndexListView.as_view(), name="index_all"), + url( + r"^question-detail/(?P\d+)/$", + views.QuestionDetailView.as_view(), + name="question_detail", + ), + url(r"^ask-question/$", views.CreateQuestionView.as_view(), name="ask_question"), + url( + r"^propose-answer/(?P\d+)/$", + views.CreateAnswerView.as_view(), + name="propose_answer", + ), + url(r"^question/vote/$", views.question_vote, name="question_vote"), + url(r"^answer/vote/$", views.answer_vote, name="answer_vote"), + url(r"^accept-answer/$", views.accept_answer, name="accept_answer"), ] diff --git a/bootcamp/qa/views.py b/bootcamp/qa/views.py index e42d5da25..75236e940 100755 --- a/bootcamp/qa/views.py +++ b/bootcamp/qa/views.py @@ -11,10 +11,13 @@ from bootcamp.helpers import ajax_required from bootcamp.qa.models import Question, Answer from bootcamp.qa.forms import QuestionForm +from bootcamp.helpers import is_owner +from bootcamp.helpers import update_votes class QuestionsIndexListView(LoginRequiredMixin, ListView): """CBV to render a list view with all the registered questions.""" + model = Question paginate_by = 20 context_object_name = "questions" @@ -29,6 +32,7 @@ def get_context_data(self, *args, **kwargs): class QuestionAnsListView(QuestionsIndexListView): """CBV to render a list view with all question which have been already marked as answered.""" + def get_queryset(self, **kwargs): return Question.objects.get_answered() @@ -41,6 +45,7 @@ def get_context_data(self, *args, **kwargs): class QuestionListView(QuestionsIndexListView): """CBV to render a list view with all question which haven't been marked as answered.""" + def get_queryset(self, **kwargs): return Question.objects.get_unanswered() @@ -53,14 +58,26 @@ def get_context_data(self, *args, **kwargs): class QuestionDetailView(LoginRequiredMixin, DetailView): """View to call a given Question object and to render all the details about that Question.""" + model = Question context_object_name = "question" + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + question = self.get_object() + if self.request.user.username == question.user.username: + is_question_owner = True + else: + is_question_owner = False + context["is_question_owner"] = is_question_owner + return context + class CreateQuestionView(LoginRequiredMixin, CreateView): """ View to handle the creation of a new question """ + form_class = QuestionForm template_name = "qa/question_form.html" message = _("Your question has been created.") @@ -78,8 +95,9 @@ class CreateAnswerView(LoginRequiredMixin, CreateView): """ View to create new answers for a given question """ + model = Answer - fields = ["content", ] + fields = ["content"] message = _("Thank you! Your answer has been posted.") def form_valid(self, form): @@ -89,8 +107,7 @@ def form_valid(self, form): def get_success_url(self): messages.success(self.request, self.message) - return reverse( - "qa:question_detail", kwargs={"pk": self.kwargs["question_id"]}) + return reverse("qa:question_detail", kwargs={"pk": self.kwargs["question_id"]}) @login_required @@ -100,24 +117,28 @@ def question_vote(request): """Function view to receive AJAX call, returns the count of votes a given question has recieved.""" question_id = request.POST["question"] - value = None - if request.POST["value"] == "U": - value = True + question = Question.objects.get(pk=question_id) + is_question_owner = is_owner(question, request.user.username) + if is_question_owner: + return JsonResponse( + { + "message": _("You can't vote your own question."), + "is_owner": is_question_owner, + } + ) - else: - value = False + value = True if request.POST["value"] == "U" else False - question = Question.objects.get(pk=question_id) try: - question.votes.update_or_create( - user=request.user, defaults={"value": value}, ) - question.count_votes() - return JsonResponse({"votes": question.total_votes}) + update_votes(question, request.user, value) + return JsonResponse( + {"votes": question.total_votes, "is_owner": is_question_owner} + ) except IntegrityError: # pragma: no cover - return JsonResponse({'status': 'false', - 'message': _("Database integrity error.")}, - status=500) + return JsonResponse( + {"status": "false", "message": _("Database integrity error.")}, status=500 + ) @login_required @@ -127,24 +148,26 @@ def answer_vote(request): """Function view to receive AJAX call, returns the count of votes a given answer has recieved.""" answer_id = request.POST["answer"] - value = None - if request.POST["value"] == "U": - value = True + answer = Answer.objects.get(uuid_id=answer_id) + is_answer_owner = is_owner(answer, request.user.username) + if is_answer_owner: + return JsonResponse( + { + "message": _("You can't vote your own answer."), + "is_owner": is_answer_owner, + } + ) - else: - value = False + value = True if request.POST["value"] == "U" else False - answer = Answer.objects.get(uuid_id=answer_id) try: - answer.votes.update_or_create( - user=request.user, defaults={"value": value}, ) - answer.count_votes() - return JsonResponse({"votes": answer.total_votes}) + update_votes(answer, request.user, value) + return JsonResponse({"votes": answer.total_votes, "is_owner": is_answer_owner}) except IntegrityError: # pragma: no cover - return JsonResponse({'status': 'false', - 'message': _("Database integrity error.")}, - status=500) + return JsonResponse( + {"status": "false", "message": _("Database integrity error.")}, status=500 + ) @login_required @@ -156,4 +179,4 @@ def accept_answer(request): answer_id = request.POST["answer"] answer = Answer.objects.get(uuid_id=answer_id) answer.accept_answer() - return JsonResponse({'status': 'true'}, status=200) + return JsonResponse({"status": "true"}, status=200) diff --git a/bootcamp/search/apps.py b/bootcamp/search/apps.py index 535cc8173..e7ebada17 100755 --- a/bootcamp/search/apps.py +++ b/bootcamp/search/apps.py @@ -3,5 +3,5 @@ class SearchConfig(AppConfig): - name = 'bootcamp.search' - verbose_name = _('Search') + name = "bootcamp.search" + verbose_name = _("Search") diff --git a/bootcamp/search/tests/test_views.py b/bootcamp/search/tests/test_views.py index da5f0f383..61f8536c9 100755 --- a/bootcamp/search/tests/test_views.py +++ b/bootcamp/search/tests/test_views.py @@ -13,6 +13,7 @@ class SearchViewsTests(TestCase): Includes tests for all the functionality associated with Views """ + def setUp(self): self.user = self.make_user("first_user") self.other_user = self.make_user("second_user") @@ -27,16 +28,25 @@ def setUp(self): wants to publish a test, just a test; everybody always wants the real deal.""" self.article = Article.objects.create( - user=self.user, title="A really nice first title", - content=self.content, tags="list, lists", status="P") - self.article_2 = Article.objects.create(user=self.other_user, - title="A first bad title", - content="First bad content", - tags="bad", status="P") + user=self.user, + title="A really nice first title", + content=self.content, + tags="list, lists", + status="P", + ) + self.article_2 = Article.objects.create( + user=self.other_user, + title="A first bad title", + content="First bad content", + tags="bad", + status="P", + ) self.question_one = Question.objects.create( - user=self.user, title="This is the first sample question", + user=self.user, + title="This is the first sample question", content="This is a sample question description for the first time", - tags="test1,test2") + tags="test1,test2", + ) self.question_two = Question.objects.create( user=self.user, title="The first shortes title", @@ -45,14 +55,15 @@ def setUp(self): publish it first, because they know this is just a test, and you know than nobody wants to publish a test, just a test; everybody always wants the real deal.""", - has_answer=True, tags="test1,test2" + has_answer=True, + tags="test1,test2", + ) + self.news_one = News.objects.create( + user=self.user, content="This is the first lazy content." ) - self.news_one = News.objects.create(user=self.user, - content="This is the first lazy content.") def test_news_search_results(self): - response = self.client.get( - reverse("search:results"), {'query': 'This is'}) + response = self.client.get(reverse("search:results"), {"query": "This is"}) assert response.status_code == 200 assert self.news_one in response.context["news_list"] assert self.question_one in response.context["questions_list"] @@ -61,10 +72,12 @@ def test_news_search_results(self): def test_questions_suggestions_results(self): response = self.client.get( - reverse("search:suggestions"), {'term': 'first'}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - assert response.json()[0]['value'] == "first_user" - assert response.json()[1]['value'] == "A first bad title" - assert response.json()[2]['value'] == "A really nice first title" - assert response.json()[3]['value'] == "The first shortes title" - assert response.json()[4]['value'] == "This is the first sample question" + reverse("search:suggestions"), + {"term": "first"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + assert response.json()[0]["value"] == "first_user" + assert response.json()[1]["value"] == "A first bad title" + assert response.json()[2]["value"] == "A really nice first title" + assert response.json()[3]["value"] == "The first shortes title" + assert response.json()[4]["value"] == "This is the first sample question" diff --git a/bootcamp/search/urls.py b/bootcamp/search/urls.py index be661d33b..1d6633dd4 100755 --- a/bootcamp/search/urls.py +++ b/bootcamp/search/urls.py @@ -2,8 +2,8 @@ from bootcamp.search import views -app_name = 'search' +app_name = "search" urlpatterns = [ - url(r'^$', views.SearchListView.as_view(), name='results'), - url(r'^suggestions/$', views.get_suggestions, name='suggestions'), + url(r"^$", views.SearchListView.as_view(), name="results"), + url(r"^suggestions/$", views.get_suggestions, name="suggestions"), ] diff --git a/bootcamp/search/views.py b/bootcamp/search/views.py index 5064c3d62..6aeb828fa 100755 --- a/bootcamp/search/views.py +++ b/bootcamp/search/views.py @@ -15,34 +15,47 @@ class SearchListView(LoginRequiredMixin, ListView): """CBV to contain all the search results""" + model = News template_name = "search/search_results.html" def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) query = self.request.GET.get("query") - context["active"] = 'news' + context["active"] = "news" context["hide_search"] = True context["tags_list"] = Tag.objects.filter(name=query) context["news_list"] = News.objects.filter( - content__icontains=query, reply=False).distinct() - context["articles_list"] = Article.objects.filter(Q( - title__icontains=query) | Q(content__icontains=query) | Q( - tags__name__icontains=query), status="P").distinct() + content__icontains=query, reply=False + ).distinct() + context["articles_list"] = Article.objects.filter( + Q(title__icontains=query) + | Q(content__icontains=query) + | Q(tags__name__icontains=query), + status="P", + ).distinct() context["questions_list"] = Question.objects.filter( - Q(title__icontains=query) | Q(content__icontains=query) | Q( - tags__name__icontains=query)).distinct() - context["users_list"] = get_user_model().objects.filter( - Q(username__icontains=query) | Q( - name__icontains=query)).distinct() + Q(title__icontains=query) + | Q(content__icontains=query) + | Q(tags__name__icontains=query) + ).distinct() + context["users_list"] = ( + get_user_model() + .objects.filter(Q(username__icontains=query) | Q(name__icontains=query)) + .distinct() + ) context["news_count"] = context["news_list"].count() context["articles_count"] = context["articles_list"].count() context["questions_count"] = context["questions_list"].count() context["users_count"] = context["users_list"].count() context["tags_count"] = context["tags_list"].count() - context["total_results"] = context["news_count"] + \ - context["articles_count"] + context["questions_count"] + \ - context["users_count"] + context["tags_count"] + context["total_results"] = ( + context["news_count"] + + context["articles_count"] + + context["questions_count"] + + context["users_count"] + + context["tags_count"] + ) return context @@ -52,14 +65,27 @@ def get_context_data(self, *args, **kwargs): def get_suggestions(request): # Convert users, articles, questions objects into list to be # represented as a single list. - query = request.GET.get('term', '') - users = list(get_user_model().objects.filter( - Q(username__icontains=query) | Q(name__icontains=query))) - articles = list(Article.objects.filter( - Q(title__icontains=query) | Q(content__icontains=query) | Q( - tags__name__icontains=query), status="P")) - questions = list(Question.objects.filter(Q(title__icontains=query) | Q( - content__icontains=query) | Q(tags__name__icontains=query))) + query = request.GET.get("term", "") + users = list( + get_user_model().objects.filter( + Q(username__icontains=query) | Q(name__icontains=query) + ) + ) + articles = list( + Article.objects.filter( + Q(title__icontains=query) + | Q(content__icontains=query) + | Q(tags__name__icontains=query), + status="P", + ) + ) + questions = list( + Question.objects.filter( + Q(title__icontains=query) + | Q(content__icontains=query) + | Q(tags__name__icontains=query) + ) + ) # Add all the retrieved users, articles, questions to data_retrieved # list. data_retrieved = users @@ -69,19 +95,19 @@ def get_suggestions(request): for data in data_retrieved: data_json = {} if isinstance(data, get_user_model()): - data_json['id'] = data.id - data_json['label'] = data.username - data_json['value'] = data.username + data_json["id"] = data.id + data_json["label"] = data.username + data_json["value"] = data.username if isinstance(data, Article): - data_json['id'] = data.id - data_json['label'] = data.title - data_json['value'] = data.title + data_json["id"] = data.id + data_json["label"] = data.title + data_json["value"] = data.title if isinstance(data, Question): - data_json['id'] = data.id - data_json['label'] = data.title - data_json['value'] = data.title + data_json["id"] = data.id + data_json["label"] = data.title + data_json["value"] = data.title results.append(data_json) diff --git a/bootcamp/static/css/news.css b/bootcamp/static/css/news.css index 2c71eec3c..796fe5936 100755 --- a/bootcamp/static/css/news.css +++ b/bootcamp/static/css/news.css @@ -1,14 +1,14 @@ -.stream { - margin: 2em 0 0 0; - padding: 0; -} - -.stream li { +.infinite-container li { list-style: none; border-style: solid none solid none; } +.stream { + margin: 2em 0 0 0; + padding: 0; +} + .stream li:last-child { border-bottom: none; } @@ -35,11 +35,6 @@ border-radius: 50%; } -.interaction { - padding-left: 1.5em; - margin-bottom: 1em -} - .interaction a { margin-right: 1.3em; font-size: 1.1em; @@ -159,3 +154,38 @@ .remove-news:hover { color: #333333; } + +.meta.card { + background-color: #f8f8f8; + margin-bottom: 10px; + color: #6c757d; +} + +.meta.card:hover { + background-color: #fff; +} + +.meta .card-body { + padding: 0.8rem; +} + +.meta .card-title, .meta .card-text, .meta .card-btn{ + font-size: 0.8em; + line-height: 1.4em; + margin: 0; +} + +.meta .card-img-top{ + height: 100px; + background: center no-repeat #ccc; + background-size: cover; +} + +.meta .card-text{ + margin-bottom: 5px; +} + +.meta:hover .btn{ + background-color: #888; + color: #FFF; +} \ No newline at end of file diff --git a/bootcamp/static/css/qa.css b/bootcamp/static/css/qa.css index 4eea5245b..947cf54a0 100755 --- a/bootcamp/static/css/qa.css +++ b/bootcamp/static/css/qa.css @@ -46,7 +46,7 @@ margin-top: .4em; } -.options i.vote { +.options i.vote, .options i.is-owner { font-size: 2em; color: #ddd; } diff --git a/bootcamp/static/js/articles.js b/bootcamp/static/js/articles.js index a10deb9c8..4b7d675b4 100755 --- a/bootcamp/static/js/articles.js +++ b/bootcamp/static/js/articles.js @@ -1,18 +1,18 @@ $(function () { - $(".publish").click(function () { - $("input[name='status']").val("P"); - $("#article-form").submit(); - }); + $(".publish").click(function () { + $("input[name='status']").val("P"); + $("#article-form").submit(); + }); - $(".update").click(function () { - $("input[name='status']").val("P"); - //$("input[name='edited']").prop("checked"); - $("input[name='edited']").val("True"); - $("#article-form").submit(); - }); + $(".update").click(function () { + $("input[name='status']").val("P"); + //$("input[name='edited']").prop("checked"); + $("input[name='edited']").val("True"); + $("#article-form").submit(); + }); - $(".draft").click(function () { - $("input[name='status']").val("D"); - $("#article-form").submit(); - }); + $(".draft").click(function () { + $("input[name='status']").val("D"); + $("#article-form").submit(); + }); }); diff --git a/bootcamp/static/js/bootcamp.js b/bootcamp/static/js/bootcamp.js index 76dd1eaa2..016acf500 100755 --- a/bootcamp/static/js/bootcamp.js +++ b/bootcamp/static/js/bootcamp.js @@ -22,7 +22,7 @@ $('.form-group').removeClass('row'); /* Notifications JS basic client */ $(function () { - let emptyMessage = 'You have no unread notification'; + let emptyMessage = 'data-empty="true"'; function checkNotifications() { $.ajax({ diff --git a/bootcamp/static/js/messager.js b/bootcamp/static/js/messager.js index a1c1b8aa6..41298454e 100755 --- a/bootcamp/static/js/messager.js +++ b/bootcamp/static/js/messager.js @@ -1,113 +1,113 @@ $(function () { - function setUserOnlineOffline(username, status) { - /* This function enables the client to switch the user connection - status, allowing to show if an user is connected or not. - */ - var elem = $("online-stat-" + username); - if (elem) { - if (status === 'online') { - elem.attr("class", "btn btn-success btn-circle"); - } else { - elem.attr("class", "btn btn-danger btn-circle"); - }; - }; + function setUserOnlineOffline(username, status) { + /* This function enables the client to switch the user connection + status, allowing to show if an user is connected or not. + */ + var elem = $("online-stat-" + username); + if (elem) { + if (status === 'online') { + elem.attr("class", "btn btn-success btn-circle"); + } else { + elem.attr("class", "btn btn-danger btn-circle"); + }; }; + }; - function addNewMessage(message_id) { - /* This function calls the respective AJAX view, so it will be able to - load the received message in a proper way. - */ - $.ajax({ - url: '/messages/receive-message/', - data: {'message_id': message_id}, - cache: false, - success: function (data) { - $(".send-message").before(data); - scrollConversationScreen(); - } - }); - }; + function addNewMessage(message_id) { + /* This function calls the respective AJAX view, so it will be able to + load the received message in a proper way. + */ + $.ajax({ + url: '/messages/receive-message/', + data: { 'message_id': message_id }, + cache: false, + success: function (data) { + $(".send-message").before(data); + scrollConversationScreen(); + } + }); + }; - function scrollConversationScreen() { - /* Set focus on the input box from the form, and rolls to show the - the most recent message. - */ - $("input[name='message']").focus(); - $('.conversation').scrollTop($('.conversation')[0].scrollHeight); - } + function scrollConversationScreen() { + /* Set focus on the input box from the form, and rolls to show the + the most recent message. + */ + $("input[name='message']").focus(); + $('.conversation').scrollTop($('.conversation')[0].scrollHeight); + } - $("#send").submit(function () { - $.ajax({ - url: '/messages/send-message/', - data: $("#send").serialize(), - cache: false, - type: 'POST', - success: function (data) { - $(".send-message").before(data); - $("input[name='message']").val(''); - scrollConversationScreen(); - } - }); - return false; + $("#send").submit(function () { + $.ajax({ + url: '/messages/send-message/', + data: $("#send").serialize(), + cache: false, + type: 'POST', + success: function (data) { + $(".send-message").before(data); + $("input[name='message']").val(''); + scrollConversationScreen(); + } }); + return false; + }); - // WebSocket connection management block. - // Correctly decide between ws:// and wss:// - var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws"; - var ws_path = ws_scheme + '://' + window.location.host + "/" + currentUser + "/"; - var webSocket = new channels.WebSocketBridge(); - webSocket.connect(ws_path); + // WebSocket connection management block. + // Correctly decide between ws:// and wss:// + var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws"; + var ws_path = ws_scheme + '://' + window.location.host + "/" + currentUser + "/"; + var webSocket = new channels.WebSocketBridge(); + webSocket.connect(ws_path); - window.onbeforeunload = function () { - // Small function to run instruction just before closing the session. - payload = { - "type": "recieve", - "sender": currentUser, - "set_status": "offline" - }; - webSocket.send(payload); - } + window.onbeforeunload = function () { + // Small function to run instruction just before closing the session. + payload = { + "type": "recieve", + "sender": currentUser, + "set_status": "offline" + }; + webSocket.send(payload); + } - // Helpful debugging - webSocket.socket.onopen = function () { - console.log("Connected to inbox stream"); - // Commenting this block until I find a better way to manage how to - // report the user status. + // Helpful debugging + webSocket.socket.onopen = function () { + console.log("Connected to inbox stream"); + // Commenting this block until I find a better way to manage how to + // report the user status. - /* payload = { - "type": "recieve", - "sender": currentUser, - "set_status": "online" - }; - webSocket.send(payload); */ + /* payload = { + "type": "recieve", + "sender": currentUser, + "set_status": "online" }; + webSocket.send(payload); */ + }; - webSocket.socket.onclose = function () { - console.log("Disconnected from inbox stream"); - }; + webSocket.socket.onclose = function () { + console.log("Disconnected from inbox stream"); + }; - // onmessage management. - webSocket.listen(function(event) { - switch (event.key) { - case "message": - if (event.sender === activeUser) { - addNewMessage(event.message_id); - // I hope there is a more elegant way to work this out. - setTimeout(function(){$("#unread-count").hide()}, 1); - } else { - $("#new-message-" + event.sender).show(); - } - break; + // onmessage management. + webSocket.listen(function (event) { + switch (event.key) { + case "message": + if (event.sender === activeUser) { + addNewMessage(event.message_id); + // I hope there is a more elegant way to work this out. + setTimeout(function () { $("#unread-count").hide() }, 1); + } else { + $("#new-message-" + event.sender).show(); + } + break; - case "set_status": - setUserOnlineOffline(event.sender, event.status); - break; + case "set_status": + setUserOnlineOffline(event.sender, event.status); + break; - default: - console.log('error: ', event); - console.log(typeof(event)) - break; - } - }); + default: + console.log('error: ', event); + console.log(typeof (event)) + break; + } + }); }); diff --git a/bootcamp/static/js/news.js b/bootcamp/static/js/news.js index 79780a041..96e71a579 100755 --- a/bootcamp/static/js/news.js +++ b/bootcamp/static/js/news.js @@ -1,192 +1,192 @@ $(function () { - function hide_stream_update() { - $(".stream-update").hide(); - }; + function hide_stream_update() { + $(".stream-update").hide(); + }; - function getCookie(name) { - // Function to get any cookie available in the session. - var cookieValue = null; - if (document.cookie && document.cookie !== '') { - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var cookie = jQuery.trim(cookies[i]); - // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } + function getCookie(name) { + // Function to get any cookie available in the session. + var cookieValue = null; + if (document.cookie && document.cookie !== '') { + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = jQuery.trim(cookies[i]); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; } - return cookieValue; - }; - - function csrfSafeMethod(method) { - // These HTTP methods do not require CSRF protection - return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); + } } + return cookieValue; + }; - var csrftoken = getCookie('csrftoken'); - var page_title = $(document).attr("title"); - // This sets up every ajax call with proper headers. - $.ajaxSetup({ - beforeSend: function(xhr, settings) { - if (!csrfSafeMethod(settings.type) && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } - } - }); + function csrfSafeMethod(method) { + // These HTTP methods do not require CSRF protection + return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); + } - // Focus on the modal input by default. - $('#newsFormModal').on('shown.bs.modal', function () { - $('#newsInput').trigger('focus') - }); + var csrftoken = getCookie('csrftoken'); + var page_title = $(document).attr("title"); + // This sets up every ajax call with proper headers. + $.ajaxSetup({ + beforeSend: function (xhr, settings) { + if (!csrfSafeMethod(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken); + } + } + }); - $('#newsThreadModal').on('shown.bs.modal', function () { - $('#replyInput').trigger('focus') - }); + // Focus on the modal input by default. + $('#newsFormModal').on('shown.bs.modal', function () { + $('#newsInput').trigger('focus') + }); - // Counts textarea characters to provide data to user. - $("#newsInput").keyup(function () { - var charCount = $(this).val().length; - $("#newsCounter").text(280 - charCount); - }); + $('#newsThreadModal').on('shown.bs.modal', function () { + $('#replyInput').trigger('focus') + }); - $("#replyInput").keyup(function () { - var charCount = $(this).val().length; - $("#replyCounter").text(280 - charCount); - }); + // Counts textarea characters to provide data to user. + $("#newsInput").keyup(function () { + var charCount = $(this).val().length; + $("#newsCounter").text(280 - charCount); + }); - $("input, textarea").attr("autocomplete", "off"); + $("#replyInput").keyup(function () { + var charCount = $(this).val().length; + $("#replyCounter").text(280 - charCount); + }); - $("#postNews").click(function () { - // Ajax call after pushing button, to register a News object. - $.ajax({ - url: '/news/post-news/', - data: $("#postNewsForm").serialize(), - type: 'POST', - cache: false, - success: function (data) { - $("ul.stream").prepend(data); - $("#newsInput").val(""); - $("#newsFormModal").modal("hide"); - hide_stream_update(); - }, - error : function(data){ - alert(data.responseText); - }, - }); + $("input, textarea").attr("autocomplete", "off"); + + $("#postNews").click(function () { + // Ajax call after pushing button, to register a News object. + $.ajax({ + url: '/news/post-news/', + data: $("#postNewsForm").serialize(), + type: 'POST', + cache: false, + success: function (data) { + $("ul.stream").prepend(data); + $("#newsInput").val(""); + $("#newsFormModal").modal("hide"); + hide_stream_update(); + }, + error: function (data) { + alert(data.responseText); + }, }); + }); - $("#replyNews").click(function () { - // Ajax call to register a reply to any given News object. - $.ajax({ - url: '/news/post-comment/', - data: $("#replyNewsForm").serialize(), - type: 'POST', - cache: false, - success: function (data) { - $("#replyInput").val(""); - $("#newsThreadModal").modal("hide"); - }, - error: function(data){ - alert(data.responseText); - }, - }); + $("#replyNews").click(function () { + // Ajax call to register a reply to any given News object. + $.ajax({ + url: '/news/post-comment/', + data: $("#replyNewsForm").serialize(), + type: 'POST', + cache: false, + success: function (data) { + $("#replyInput").val(""); + $("#newsThreadModal").modal("hide"); + }, + error: function (data) { + alert(data.responseText); + }, }); + }); - $("ul.stream").on("click", ".like", function () { - // Ajax call on action on like button. - var li = $(this).closest("li"); - var news = $(li).attr("news-id"); - payload = { - 'news': news, - 'csrf_token': csrftoken + $("ul.stream").on("click", ".like", function () { + // Ajax call on action on like button. + var li = $(this).closest("li"); + var news = $(li).attr("news-id"); + payload = { + 'news': news, + 'csrf_token': csrftoken + } + $.ajax({ + url: '/news/like/', + data: payload, + type: 'POST', + cache: false, + success: function (data) { + $(".like .like-count", li).text(data.likes); + if ($(".like .heart", li).hasClass("fa fa-heart")) { + $(".like .heart", li).removeClass("fa fa-heart"); + $(".like .heart", li).addClass("fa fa-heart-o"); + } else { + $(".like .heart", li).removeClass("fa fa-heart-o"); + $(".like .heart", li).addClass("fa fa-heart"); } - $.ajax({ - url: '/news/like/', - data: payload, - type: 'POST', - cache: false, - success: function (data) { - $(".like .like-count", li).text(data.likes); - if ($(".like .heart", li).hasClass("fa fa-heart")) { - $(".like .heart", li).removeClass("fa fa-heart"); - $(".like .heart", li).addClass("fa fa-heart-o"); - } else { - $(".like .heart", li).removeClass("fa fa-heart-o"); - $(".like .heart", li).addClass("fa fa-heart"); - } - } - }); - return false; + } }); + return false; + }); - $("ul.stream").on("click", ".comment", function () { - // Ajax call to request a given News object detail and thread, and to - // show it in a modal. - var post = $(this).closest(".card"); - var news = $(post).closest("li").attr("news-id"); - $("#newsThreadModal").modal("show"); - $.ajax({ - url: '/news/get-thread/', - data: {'news': news}, - cache: false, - beforeSend: function () { - $("#threadContent").html("
  • "); - }, - success: function (data) { - $("input[name=parent]").val(data.uuid) - $("#newsContent").html(data.news); - $("#threadContent").html(data.thread); - } - }); - return false; + $("ul.stream").on("click", ".comment", function () { + // Ajax call to request a given News object detail and thread, and to + // show it in a modal. + var post = $(this).closest(".card"); + var news = $(post).closest("li").attr("news-id"); + $("#newsThreadModal").modal("show"); + $.ajax({ + url: '/news/get-thread/', + data: { 'news': news }, + cache: false, + beforeSend: function () { + $("#threadContent").html("
  • "); + }, + success: function (data) { + $("input[name=parent]").val(data.uuid) + $("#newsContent").html(data.news); + $("#threadContent").html(data.thread); + } }); + return false; + }); }); /* Example query for the GraphQL endpoint. - query{ - news(uuidId: "--insert here the required uuid_id value for the lookup"){ - uuidId - content - timestamp - countThread - countLikers - user { - name - picture - } - liked { - name - } - thread{ - content - } - } - paginatedNews(page: 1){ - page - pages - hasNext - hasPrev - objects { - uuidId - content - timestamp - countThread - countLikers - user { - name - picture - } - liked { - name - } - thread{ - content - } - } - } + query{ + news(uuidId: "--insert here the required uuid_id value for the lookup"){ + uuidId + content + timestamp + countThread + countLikers + user { + name + picture + } + liked { + name + } + thread{ + content + } + } + paginatedNews(page: 1){ + page + pages + hasNext + hasPrev + objects { + uuidId + content + timestamp + countThread + countLikers + user { + name + picture + } + liked { + name + } + thread{ + content } + } + } + } */ diff --git a/bootcamp/static/js/qa.js b/bootcamp/static/js/qa.js index 2427fb0ad..31dd35421 100755 --- a/bootcamp/static/js/qa.js +++ b/bootcamp/static/js/qa.js @@ -1,124 +1,131 @@ $(function () { - function getCookie(name) { - // Function to get any cookie available in the session. - var cookieValue = null; - if (document.cookie && document.cookie !== '') { - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var cookie = jQuery.trim(cookies[i]); - // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } + function getCookie(name) { + // Function to get any cookie available in the session. + var cookieValue = null; + if (document.cookie && document.cookie !== '') { + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = jQuery.trim(cookies[i]); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; } - return cookieValue; - }; + } + } + return cookieValue; + }; + + function csrfSafeMethod(method) { + // These HTTP methods do not require CSRF protection + return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); + } - function csrfSafeMethod(method) { - // These HTTP methods do not require CSRF protection - return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); + function toogleVote(voteIcon, vote, data, isAnswer) { + var idPrefix = isAnswer ? 'answer' : 'question'; + var isOwner = data.is_owner; + if (isOwner === false) { + if (vote === "U") { + voteIcon.addClass('voted'); + voteIcon.siblings(`#${idPrefix}DownVote`).removeClass('voted'); + } else { + voteIcon.addClass('voted'); + voteIcon.siblings(`#${idPrefix}UpVote`).removeClass('voted'); + } + voteIcon.siblings(`#${idPrefix}Votes`).text(data.votes); } + } - var csrftoken = getCookie('csrftoken'); - var page_title = $(document).attr("title"); - // This sets up every ajax call with proper headers. - $.ajaxSetup({ - beforeSend: function(xhr, settings) { - if (!csrfSafeMethod(settings.type) && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } - } - }); + var csrftoken = getCookie('csrftoken'); + var page_title = $(document).attr("title"); + // This sets up every ajax call with proper headers. + $.ajaxSetup({ + beforeSend: function (xhr, settings) { + if (!csrfSafeMethod(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken); + } + } + }); - $("#publish").click(function () { - // function to operate the Publish button in the question form, marking - // the question status as published. - $("input[name='status']").val("O"); - $("#question-form").submit(); - }); + $("#publish").click(function () { + // function to operate the Publish button in the question form, marking + // the question status as published. + $("input[name='status']").val("O"); + $("#question-form").submit(); + }); - $("#draft").click(function () { - // Function to operate the Draft button in the question form, marking - // the question status as draft. - $("input[name='status']").val("D"); - $("#question-form").submit(); - }); + $("#draft").click(function () { + // Function to operate the Draft button in the question form, marking + // the question status as draft. + $("input[name='status']").val("D"); + $("#question-form").submit(); + }); - $(".question-vote").click(function () { - // Vote on a question. - var span = $(this); - var question = $(this).closest(".question").attr("question-id"); - vote = null; - if ($(this).hasClass("up-vote")) { - vote = "U"; - } else { - vote = "D"; - } - $.ajax({ - url: '/qa/question/vote/', - data: { - 'question': question, - 'value': vote - }, - type: 'post', - cache: false, - success: function (data) { - $('.vote', span).removeClass('voted'); - if (vote === "U") { - $(span).addClass('voted'); - } - $("#questionVotes").text(data.votes); - } - }); + $(".question-vote").click(function () { + // Vote on a question. + var voteIcon = $(this); + var question = $(this).closest(".question").attr("question-id"); + if ($(this).hasClass("up-vote")) { + vote = "U"; + } else { + $('#questionDownVote').addClass('voted'); + $('#questionUpVote').removeClass('voted'); + } + $.ajax({ + url: '/qa/question/vote/', + data: { + 'question': question, + 'value': vote + }, + type: 'post', + cache: false, + success: function (data) { + toogleVote(voteIcon, vote, data, false); + } }); + }); - $(".answer-vote").click(function () { - // Vote on an answer. - var span = $(this); - var answer = $(this).closest(".answer").attr("answer-id"); - vote = null; - if ($(this).hasClass("up-vote")) { - vote = "U"; - } else { - vote = "D"; - } - $.ajax({ - url: '/qa/answer/vote/', - data: { - 'answer': answer, - 'value': vote - }, - type: 'post', - cache: false, - success: function (data) { - $('.vote', span).removeClass('voted'); - if (vote === "U") { - $(span).addClass('voted'); - } - $("#answerVotes").text(data.votes); - } - }); + $(".answer-vote").click(function () { + // Vote on an answer. + var voteIcon = $(this); + var answer = $(this).closest(".answer").attr("answer-id"); + if ($(this).hasClass("up-vote")) { + vote = "U"; + } else { + $('#answerDownVote').addClass('voted'); + $('#answerUpVote').removeClass('voted'); + } + $.ajax({ + url: '/qa/answer/vote/', + data: { + 'answer': answer, + 'value': vote + }, + type: 'post', + cache: false, + success: function (data) { + toogleVote(voteIcon, vote, data, true); + } }); + }); - $("#acceptAnswer").click(function () { - // Mark an answer as accepted. - var span = $(this); - var answer = $(this).closest(".answer").attr("answer-id"); - $.ajax({ - url: '/qa/accept-answer/', - data: { - 'answer': answer - }, - type: 'post', - cache: false, - success: function (data) { - $("#acceptAnswer").removeClass("accepted"); - $("#acceptAnswer").prop("title", "Click to accept the answer"); - $("#acceptAnswer").addClass("accepted"); - $("#acceptAnswer").prop("title", "Click to unaccept the answer"); - } - }); + $("#acceptAnswer").click(function () { + // Mark an answer as accepted. + var span = $(this); + var answer = $(this).closest(".answer").attr("answer-id"); + $.ajax({ + url: '/qa/accept-answer/', + data: { + 'answer': answer + }, + type: 'post', + cache: false, + success: function (data) { + $("#acceptAnswer").removeClass("accepted"); + $("#acceptAnswer").prop("title", "Click to accept the answer"); + $("#acceptAnswer").addClass("accepted"); + $("#acceptAnswer").prop("title", "Click to unaccept the answer"); + } }); + }); }); diff --git a/bootcamp/templates/articles/article_detail.html b/bootcamp/templates/articles/article_detail.html index c0a39c30f..d5348719c 100755 --- a/bootcamp/templates/articles/article_detail.html +++ b/bootcamp/templates/articles/article_detail.html @@ -47,7 +47,12 @@
    {% trans 'Leave a Comment' %}:
    {% get_comment_form for article as form %}
    {% csrf_token %} - {{ form|crispy }} + {{ form.comment|as_crispy_field }} + {{ form.honeyspot }} + {{ form.content_type }} + {{ form.object_pk }} + {{ form.timestamp }} + {{ form.security_hash }}
    @@ -67,7 +72,7 @@
    {% trans 'Leave a Comment' %}:
    {% endthumbnail %}
    {{ comment.user.get_profile_name|title }}
    - {{ comment }} + {{ comment.comment|linebreaks }}
    {% endfor %} diff --git a/bootcamp/templates/base.html b/bootcamp/templates/base.html index 4d6b14c80..e88caaabf 100755 --- a/bootcamp/templates/base.html +++ b/bootcamp/templates/base.html @@ -113,9 +113,8 @@ {% endblock javascript %} diff --git a/bootcamp/templates/news/news_single.html b/bootcamp/templates/news/news_single.html index 9027d41b7..1dd5b5541 100755 --- a/bootcamp/templates/news/news_single.html +++ b/bootcamp/templates/news/news_single.html @@ -1,6 +1,7 @@ {% load i18n %} {% load humanize static %} {% load thumbnail %} +{% load urlize_target_blank %}
  • -
  • diff --git a/bootcamp/templates/news/news_thread.html b/bootcamp/templates/news/news_thread.html index e64f539e5..827cacdc8 100755 --- a/bootcamp/templates/news/news_thread.html +++ b/bootcamp/templates/news/news_thread.html @@ -1,6 +1,7 @@ {% load i18n %} {% load humanize static %} {% load thumbnail %} +{% load urlize_target_blank %} {% for reply in thread %} @@ -24,22 +25,37 @@ {{ reply.user.get_profile_name|title }}

    -

    {{ reply }}

    - {% if reply.image %} - Card image cap +

    {{ reply|urlize|urlize_target_blank }}

    + {% if reply.meta_url %} + + {% if reply.meta_image %} +
    + {% endif %} +
    + {% if reply.meta_title %} +
    {{ reply.meta_title }}
    + {% endif %} + {% if reply.meta_description %} +

    {{ reply.meta_description }}

    + {% endif %} + {% if reply.meta_url %} +

    {{ reply.meta_url }}

    + {% endif %} +
    +
    {% endif %} + - {% endfor %} diff --git a/bootcamp/templates/notifications/most_recent.html b/bootcamp/templates/notifications/most_recent.html index 849e74358..42eda4196 100755 --- a/bootcamp/templates/notifications/most_recent.html +++ b/bootcamp/templates/notifications/most_recent.html @@ -15,6 +15,6 @@ {% trans 'See all' %} {% trans 'Mark all as read' %} {% else %} -
  • {% trans 'You have no unread notification' %}
  • +
  • {% trans 'You have no unread notification' %}
  • {% endif %} diff --git a/bootcamp/templates/qa/answer_sample.html b/bootcamp/templates/qa/answer_sample.html index eda25b178..fe974750b 100755 --- a/bootcamp/templates/qa/answer_sample.html +++ b/bootcamp/templates/qa/answer_sample.html @@ -3,9 +3,9 @@
    {% csrf_token %}
    - + {{ answer.total_votes }} - + {% if answer.is_answer and user == question.user %} {% elif answer.is_answer %} diff --git a/bootcamp/templates/qa/question_detail.html b/bootcamp/templates/qa/question_detail.html index b5761287f..aa9106198 100755 --- a/bootcamp/templates/qa/question_detail.html +++ b/bootcamp/templates/qa/question_detail.html @@ -27,9 +27,9 @@

    {{ question.title }}

    {{ question.count_answers }}

    {% trans 'Answers' %} - +

    {{ question.total_votes }}

    - + {% trans 'Votes' %}
    @@ -55,7 +55,7 @@

    {% trans 'Answers' %}

      {% for answer in question.answer_set.all %} - {% include 'qa/answer_sample.html' with answer=answer %} + {% include 'qa/answer_sample.html' with answer=answer username=request.user.username %} {% empty %}

      {% trans 'There are no answers yet.' %}

      diff --git a/bootcamp/users/adapters.py b/bootcamp/users/adapters.py index b31450aee..bc5b290f3 100755 --- a/bootcamp/users/adapters.py +++ b/bootcamp/users/adapters.py @@ -5,9 +5,9 @@ class AccountAdapter(DefaultAccountAdapter): def is_open_for_signup(self, request): - return getattr(settings, 'ACCOUNT_ALLOW_REGISTRATION', True) + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) class SocialAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request, sociallogin): - return getattr(settings, 'ACCOUNT_ALLOW_REGISTRATION', True) + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) diff --git a/bootcamp/users/admin.py b/bootcamp/users/admin.py index e9427e059..d395d5362 100755 --- a/bootcamp/users/admin.py +++ b/bootcamp/users/admin.py @@ -13,9 +13,9 @@ class Meta(UserChangeForm.Meta): class MyUserCreationForm(UserCreationForm): - error_message = UserCreationForm.error_messages.update({ - 'duplicate_username': 'This username has already been taken.' - }) + error_message = UserCreationForm.error_messages.update( + {"duplicate_username": "This username has already been taken."} + ) class Meta(UserCreationForm.Meta): model = User @@ -28,15 +28,13 @@ def clean_username(self): except User.DoesNotExist: return username - raise forms.ValidationError(self.error_messages['duplicate_username']) + raise forms.ValidationError(self.error_messages["duplicate_username"]) @admin.register(User) class MyUserAdmin(AuthUserAdmin): form = MyUserChangeForm add_form = MyUserCreationForm - fieldsets = ( - ('User Profile', {'fields': ('name',)}), - ) + AuthUserAdmin.fieldsets - list_display = ('username', 'name', 'is_superuser') - search_fields = ['name'] + fieldsets = (("User Profile", {"fields": ("name",)}),) + AuthUserAdmin.fieldsets + list_display = ("username", "name", "is_superuser") + search_fields = ["name"] diff --git a/bootcamp/users/apps.py b/bootcamp/users/apps.py index 476a289b5..d64326584 100755 --- a/bootcamp/users/apps.py +++ b/bootcamp/users/apps.py @@ -3,7 +3,7 @@ class UsersConfig(AppConfig): - name = 'bootcamp.users' + name = "bootcamp.users" verbose_name = _("Users") def ready(self): diff --git a/bootcamp/users/migrations/0001_initial.py b/bootcamp/users/migrations/0001_initial.py index 252c68e79..27f964e9a 100755 --- a/bootcamp/users/migrations/0001_initial.py +++ b/bootcamp/users/migrations/0001_initial.py @@ -10,46 +10,206 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ('auth', '0009_alter_user_last_name_max_length'), - ] + dependencies = [("auth", "0009_alter_user_last_name_max_length")] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('name', models.CharField(blank=True, max_length=255, verbose_name="User's name")), - ('picture', models.ImageField(blank=True, null=True, upload_to='profile_pics/', verbose_name='Profile picture')), - ('location', models.CharField(blank=True, max_length=50, null=True, verbose_name='Location')), - ('job_title', models.CharField(blank=True, max_length=50, null=True, verbose_name='Job title')), - ('personal_url', models.URLField(blank=True, max_length=555, null=True, verbose_name='Personal URL')), - ('facebook_account', models.URLField(blank=True, max_length=255, null=True, verbose_name='Facebook profile')), - ('twitter_account', models.URLField(blank=True, max_length=255, null=True, verbose_name='Twitter account')), - ('github_account', models.URLField(blank=True, max_length=255, null=True, verbose_name='GitHub profile')), - ('linkedin_account', models.URLField(blank=True, max_length=255, null=True, verbose_name='LinkedIn profile')), - ('short_bio', models.CharField(blank=True, max_length=60, null=True, verbose_name='Describe yourself')), - ('bio', models.CharField(blank=True, max_length=280, null=True, verbose_name='Short bio')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=30, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "name", + models.CharField( + blank=True, max_length=255, verbose_name="User's name" + ), + ), + ( + "picture", + models.ImageField( + blank=True, + null=True, + upload_to="profile_pics/", + verbose_name="Profile picture", + ), + ), + ( + "location", + models.CharField( + blank=True, max_length=50, null=True, verbose_name="Location" + ), + ), + ( + "job_title", + models.CharField( + blank=True, max_length=50, null=True, verbose_name="Job title" + ), + ), + ( + "personal_url", + models.URLField( + blank=True, + max_length=555, + null=True, + verbose_name="Personal URL", + ), + ), + ( + "facebook_account", + models.URLField( + blank=True, + max_length=255, + null=True, + verbose_name="Facebook profile", + ), + ), + ( + "twitter_account", + models.URLField( + blank=True, + max_length=255, + null=True, + verbose_name="Twitter account", + ), + ), + ( + "github_account", + models.URLField( + blank=True, + max_length=255, + null=True, + verbose_name="GitHub profile", + ), + ), + ( + "linkedin_account", + models.URLField( + blank=True, + max_length=255, + null=True, + verbose_name="LinkedIn profile", + ), + ), + ( + "short_bio", + models.CharField( + blank=True, + max_length=60, + null=True, + verbose_name="Describe yourself", + ), + ), + ( + "bio", + models.CharField( + blank=True, max_length=280, null=True, verbose_name="Short bio" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), ], options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), + managers=[("objects", django.contrib.auth.models.UserManager())], + ) ] diff --git a/bootcamp/users/models.py b/bootcamp/users/models.py index 0812ba152..42556bf17 100755 --- a/bootcamp/users/models.py +++ b/bootcamp/users/models.py @@ -11,31 +11,35 @@ class User(AbstractUser): # First Name and Last Name do not cover name patterns around the globe. name = models.CharField(_("User's name"), blank=True, max_length=255) picture = models.ImageField( - _('Profile picture'), upload_to='profile_pics/', null=True, blank=True) - location = models.CharField( - _('Location'), max_length=50, null=True, blank=True) - job_title = models.CharField( - _('Job title'), max_length=50, null=True, blank=True) + _("Profile picture"), upload_to="profile_pics/", null=True, blank=True + ) + location = models.CharField(_("Location"), max_length=50, null=True, blank=True) + job_title = models.CharField(_("Job title"), max_length=50, null=True, blank=True) personal_url = models.URLField( - _('Personal URL'), max_length=555, blank=True, null=True) + _("Personal URL"), max_length=555, blank=True, null=True + ) facebook_account = models.URLField( - _('Facebook profile'), max_length=255, blank=True, null=True) + _("Facebook profile"), max_length=255, blank=True, null=True + ) twitter_account = models.URLField( - _('Twitter account'), max_length=255, blank=True, null=True) + _("Twitter account"), max_length=255, blank=True, null=True + ) github_account = models.URLField( - _('GitHub profile'), max_length=255, blank=True, null=True) + _("GitHub profile"), max_length=255, blank=True, null=True + ) linkedin_account = models.URLField( - _('LinkedIn profile'), max_length=255, blank=True, null=True) + _("LinkedIn profile"), max_length=255, blank=True, null=True + ) short_bio = models.CharField( - _('Describe yourself'), max_length=60, blank=True, null=True) - bio = models.CharField( - _('Short bio'), max_length=280, blank=True, null=True) + _("Describe yourself"), max_length=60, blank=True, null=True + ) + bio = models.CharField(_("Short bio"), max_length=280, blank=True, null=True) def __str__(self): return self.username def get_absolute_url(self): - return reverse('users:detail', kwargs={'username': self.username}) + return reverse("users:detail", kwargs={"username": self.username}) def get_profile_name(self): if self.name: diff --git a/bootcamp/users/schema.py b/bootcamp/users/schema.py index 83e4533c8..5bcb4560c 100755 --- a/bootcamp/users/schema.py +++ b/bootcamp/users/schema.py @@ -6,6 +6,7 @@ class UserType(DjangoObjectType): """DjangoObjectType to acces the User model.""" + picture = graphene.String() name = graphene.String() @@ -33,7 +34,7 @@ def resolve_all_users(self, info, **kwargs): return User.objects.all() def resolve_user(self, info, **kwargs): - id = kwargs.get('id') + id = kwargs.get("id") if id is not None: return User.objects.get(id=id) diff --git a/bootcamp/users/tests/test_admin.py b/bootcamp/users/tests/test_admin.py index a1ff0b846..1afcbfda3 100755 --- a/bootcamp/users/tests/test_admin.py +++ b/bootcamp/users/tests/test_admin.py @@ -4,32 +4,35 @@ class TestMyUserCreationForm(TestCase): - def setUp(self): - self.user = self.make_user('notalamode', 'notalamodespassword') + self.user = self.make_user("notalamode", "notalamodespassword") def test_clean_username_success(self): # Instantiate the form with a new username - form = MyUserCreationForm({ - 'username': 'alamode', - 'password1': '7jefB#f@Cc7YJB]2v', - 'password2': '7jefB#f@Cc7YJB]2v', - }) + form = MyUserCreationForm( + { + "username": "alamode", + "password1": "7jefB#f@Cc7YJB]2v", + "password2": "7jefB#f@Cc7YJB]2v", + } + ) # Run is_valid() to trigger the validation valid = form.is_valid() self.assertTrue(valid) # Run the actual clean_username method username = form.clean_username() - self.assertEqual('alamode', username) + self.assertEqual("alamode", username) def test_clean_username_false(self): # Instantiate the form with the same username as self.user - form = MyUserCreationForm({ - 'username': self.user.username, - 'password1': 'notalamodespassword', - 'password2': 'notalamodespassword', - }) + form = MyUserCreationForm( + { + "username": self.user.username, + "password1": "notalamodespassword", + "password2": "notalamodespassword", + } + ) # Run is_valid() to trigger the validation, which is going to fail # because the username is already taken valid = form.is_valid() @@ -37,4 +40,4 @@ def test_clean_username_false(self): # The form.errors dict should contain a single error called 'username' self.assertTrue(len(form.errors) == 1) - self.assertTrue('username' in form.errors) + self.assertTrue("username" in form.errors) diff --git a/bootcamp/users/tests/test_models.py b/bootcamp/users/tests/test_models.py index fcefea173..7c8e1c50e 100755 --- a/bootcamp/users/tests/test_models.py +++ b/bootcamp/users/tests/test_models.py @@ -2,21 +2,17 @@ class TestUser(TestCase): - def setUp(self): self.user = self.make_user() def test__str__(self): self.assertEqual( self.user.__str__(), - "testuser" # This is the default username for self.make_user() + "testuser", # This is the default username for self.make_user() ) def test_get_absolute_url(self): - self.assertEqual( - self.user.get_absolute_url(), - "/users/testuser/" - ) + self.assertEqual(self.user.get_absolute_url(), "/users/testuser/") def test_get_profile_name(self): assert self.user.get_profile_name() == "testuser" diff --git a/bootcamp/users/tests/test_urls.py b/bootcamp/users/tests/test_urls.py index 4935b0f3e..6b072436d 100755 --- a/bootcamp/users/tests/test_urls.py +++ b/bootcamp/users/tests/test_urls.py @@ -11,41 +11,34 @@ def setUp(self): def test_list_reverse(self): """users:list should reverse to /users/.""" - self.assertEqual(reverse('users:list'), '/users/') + self.assertEqual(reverse("users:list"), "/users/") def test_list_resolve(self): """/users/ should resolve to users:list.""" - self.assertEqual(resolve('/users/').view_name, 'users:list') + self.assertEqual(resolve("/users/").view_name, "users:list") def test_redirect_reverse(self): """users:redirect should reverse to /users/~redirect/.""" - self.assertEqual(reverse('users:redirect'), '/users/~redirect/') + self.assertEqual(reverse("users:redirect"), "/users/~redirect/") def test_redirect_resolve(self): """/users/~redirect/ should resolve to users:redirect.""" - self.assertEqual( - resolve('/users/~redirect/').view_name, - 'users:redirect' - ) + self.assertEqual(resolve("/users/~redirect/").view_name, "users:redirect") def test_detail_reverse(self): """users:detail should reverse to /users/testuser/.""" self.assertEqual( - reverse('users:detail', kwargs={'username': 'testuser'}), - '/users/testuser/' + reverse("users:detail", kwargs={"username": "testuser"}), "/users/testuser/" ) def test_detail_resolve(self): """/users/testuser/ should resolve to users:detail.""" - self.assertEqual(resolve('/users/testuser/').view_name, 'users:detail') + self.assertEqual(resolve("/users/testuser/").view_name, "users:detail") def test_update_reverse(self): """users:update should reverse to /users/~update/.""" - self.assertEqual(reverse('users:update'), '/users/~update/') + self.assertEqual(reverse("users:update"), "/users/~update/") def test_update_resolve(self): """/users/~update/ should resolve to users:update.""" - self.assertEqual( - resolve('/users/~update/').view_name, - 'users:update' - ) + self.assertEqual(resolve("/users/~update/").view_name, "users:update") diff --git a/bootcamp/users/tests/test_views.py b/bootcamp/users/tests/test_views.py index 350034e9c..e4545a9f0 100755 --- a/bootcamp/users/tests/test_views.py +++ b/bootcamp/users/tests/test_views.py @@ -2,47 +2,38 @@ from test_plus.test import TestCase -from ..views import ( - UserRedirectView, - UserUpdateView -) +from ..views import UserRedirectView, UserUpdateView class BaseUserTestCase(TestCase): - def setUp(self): self.user = self.make_user() self.factory = RequestFactory() class TestUserRedirectView(BaseUserTestCase): - def test_get_redirect_url(self): # Instantiate the view directly. Never do this outside a test! view = UserRedirectView() # Generate a fake request - request = self.factory.get('/fake-url') + request = self.factory.get("/fake-url") # Attach the user to the request request.user = self.user # Attach the request to the view view.request = request # Expect: '/users/testuser/', as that is the default username for # self.make_user() - self.assertEqual( - view.get_redirect_url(), - '/users/testuser/' - ) + self.assertEqual(view.get_redirect_url(), "/users/testuser/") class TestUserUpdateView(BaseUserTestCase): - def setUp(self): # call BaseUserTestCase.setUp() super().setUp() # Instantiate the view directly. Never do this outside a test! self.view = UserUpdateView() # Generate a fake request - request = self.factory.get('/fake-url') + request = self.factory.get("/fake-url") # Attach the user to the request request.user = self.user # Attach the request to the view @@ -51,14 +42,8 @@ def setUp(self): def test_get_success_url(self): # Expect: '/users/testuser/', as that is the default username for # self.make_user() - self.assertEqual( - self.view.get_success_url(), - '/users/testuser/' - ) + self.assertEqual(self.view.get_success_url(), "/users/testuser/") def test_get_object(self): # Expect: self.user, as that is the request's user object - self.assertEqual( - self.view.get_object(), - self.user - ) + self.assertEqual(self.view.get_object(), self.user) diff --git a/bootcamp/users/urls.py b/bootcamp/users/urls.py index 1e1618363..69414f47e 100755 --- a/bootcamp/users/urls.py +++ b/bootcamp/users/urls.py @@ -2,26 +2,14 @@ from . import views -app_name = 'users' +app_name = "users" urlpatterns = [ + url(regex=r"^$", view=views.UserListView.as_view(), name="list"), + url(regex=r"^~redirect/$", view=views.UserRedirectView.as_view(), name="redirect"), + url(regex=r"^~update/$", view=views.UserUpdateView.as_view(), name="update"), url( - regex=r'^$', - view=views.UserListView.as_view(), - name='list' - ), - url( - regex=r'^~redirect/$', - view=views.UserRedirectView.as_view(), - name='redirect' - ), - url( - regex=r'^~update/$', - view=views.UserUpdateView.as_view(), - name='update' - ), - url( - regex=r'^(?P[\w.@+-]+)/$', + regex=r"^(?P[\w.@+-]+)/$", view=views.UserDetailView.as_view(), - name='detail' + name="detail", ), ] diff --git a/bootcamp/users/views.py b/bootcamp/users/views.py index 224eccb6c..d670fdcd2 100755 --- a/bootcamp/users/views.py +++ b/bootcamp/users/views.py @@ -8,28 +8,37 @@ class UserDetailView(LoginRequiredMixin, DetailView): model = User # These next two lines tell the view to index lookups by username - slug_field = 'username' - slug_url_kwarg = 'username' + slug_field = "username" + slug_url_kwarg = "username" class UserRedirectView(LoginRequiredMixin, RedirectView): permanent = False def get_redirect_url(self): - return reverse('users:detail', - kwargs={'username': self.request.user.username}) + return reverse("users:detail", kwargs={"username": self.request.user.username}) class UserUpdateView(LoginRequiredMixin, UpdateView): - fields = ['name', 'email', 'picture', 'job_title', 'location', 'personal_url', - 'facebook_account', 'twitter_account', 'github_account', - 'linkedin_account', 'short_bio', 'bio', ] + fields = [ + "name", + "email", + "picture", + "job_title", + "location", + "personal_url", + "facebook_account", + "twitter_account", + "github_account", + "linkedin_account", + "short_bio", + "bio", + ] model = User # send the user back to their own page after a successful update def get_success_url(self): - return reverse('users:detail', - kwargs={'username': self.request.user.username}) + return reverse("users:detail", kwargs={"username": self.request.user.username}) def get_object(self): # Only get the User record for the user making the request @@ -39,5 +48,5 @@ def get_object(self): class UserListView(LoginRequiredMixin, ListView): model = User # These next two lines tell the view to index lookups by username - slug_field = 'username' - slug_url_kwarg = 'username' + slug_field = "username" + slug_url_kwarg = "username" diff --git a/compose/production/postgres/Dockerfile b/compose/production/postgres/Dockerfile index 4a848ee14..b80cdbe5a 100755 --- a/compose/production/postgres/Dockerfile +++ b/compose/production/postgres/Dockerfile @@ -1,4 +1,4 @@ -FROM postgres:10 +FROM postgres:11-alpine COPY ./compose/production/postgres/backup.sh /usr/local/bin/backup RUN chmod +x /usr/local/bin/backup diff --git a/config/asgi.py b/config/asgi.py index 9263f4251..658d2a2a0 100755 --- a/config/asgi.py +++ b/config/asgi.py @@ -7,6 +7,6 @@ import django from channels.routing import get_default_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") django.setup() application = get_default_application() diff --git a/config/routing.py b/config/routing.py index 5b9ccddd5..01a726c14 100755 --- a/config/routing.py +++ b/config/routing.py @@ -6,16 +6,21 @@ from bootcamp.messager.consumers import MessagerConsumer from bootcamp.notifications.consumers import NotificationsConsumer + # from bootcamp.notifications.routing import notifications_urlpatterns # from bootcamp.messager.routing import messager_urlpatterns -application = ProtocolTypeRouter({ - "websocket": AllowedHostsOriginValidator( - AuthMiddlewareStack( - URLRouter([ - url(r'^notifications/$', NotificationsConsumer), - url(r'^(?P[^/]+)/$', MessagerConsumer), - ]) - ), - ), -}) +application = ProtocolTypeRouter( + { + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack( + URLRouter( + [ + url(r"^notifications/$", NotificationsConsumer), + url(r"^(?P[^/]+)/$", MessagerConsumer), + ] + ) + ) + ) + } +) diff --git a/bootcamp/schema.py b/config/schema.py similarity index 100% rename from bootcamp/schema.py rename to config/schema.py diff --git a/config/settings/base.py b/config/settings/base.py index cc925cc99..ecad77e50 100755 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -4,11 +4,13 @@ import environ -ROOT_DIR = environ.Path(__file__) - 3 # (bootcamp/config/settings/base.py - 3 = bootcamp/) -APPS_DIR = ROOT_DIR.path('bootcamp') +ROOT_DIR = ( + environ.Path(__file__) - 3 +) # (bootcamp/config/settings/base.py - 3 = bootcamp/) +APPS_DIR = ROOT_DIR.path("bootcamp") env = environ.Env() -env.read_env(str(ROOT_DIR.path('.env'))) +env.read_env(str(ROOT_DIR.path(".env"))) # READ_DOT_ENV_FILE = env.bool('DJANGO_READ_DOT_ENV_FILE', default=False) # if READ_DOT_ENV_FILE: @@ -18,14 +20,14 @@ # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = env.bool('DJANGO_DEBUG', False) +DEBUG = env.bool("DJANGO_DEBUG", False) # Local time zone. Choices are # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # though not all of them may be available with every OS. # In Windows, this must be set to your system time zone. -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" # https://docs.djangoproject.com/en/dev/ref/settings/#language-code -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" # https://docs.djangoproject.com/en/dev/ref/settings/#site-id SITE_ID = 1 # https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n @@ -38,144 +40,132 @@ # DATABASES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#databases -DATABASES = { - 'default': env.db('DATABASE_URL'), -} -DATABASES['default']['ATOMIC_REQUESTS'] = True +DATABASES = {"default": env.db("DATABASE_URL")} +DATABASES["default"]["ATOMIC_REQUESTS"] = True # URLS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf -ROOT_URLCONF = 'config.urls' +ROOT_URLCONF = "config.urls" # https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application -WSGI_APPLICATION = 'config.wsgi.application' +WSGI_APPLICATION = "config.wsgi.application" # APPS # ------------------------------------------------------------------------------ DJANGO_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.humanize', - 'django.contrib.admin', - 'django.forms', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", + "django.contrib.admin", + "django.forms", ] THIRD_PARTY_APPS = [ - 'crispy_forms', - 'sorl.thumbnail', - 'allauth', - 'allauth.account', - 'allauth.socialaccount', + "crispy_forms", + "sorl.thumbnail", + "allauth", + "allauth.account", + "allauth.socialaccount", # 'allauth.socialaccount.providers.amazon', # 'allauth.socialaccount.providers.github', # 'allauth.socialaccount.providers.google', # 'allauth.socialaccount.providers.linkedin', # 'allauth.socialaccount.providers.slack', - 'channels', - 'django_comments', - 'graphene_django', - 'markdownx', - 'taggit', + "channels", + "django_comments", + "graphene_django", + "markdownx", + "taggit", ] LOCAL_APPS = [ - 'bootcamp.users.apps.UsersConfig', + "bootcamp.users.apps.UsersConfig", # Your stuff: custom apps go here - 'bootcamp.articles.apps.ArticlesConfig', - 'bootcamp.messager.apps.MessagerConfig', - 'bootcamp.news.apps.NewsConfig', - 'bootcamp.notifications.apps.NotificationsConfig', - 'bootcamp.qa.apps.QaConfig', - 'bootcamp.search.apps.SearchConfig' + "bootcamp.articles.apps.ArticlesConfig", + "bootcamp.messager.apps.MessagerConfig", + "bootcamp.news.apps.NewsConfig", + "bootcamp.notifications.apps.NotificationsConfig", + "bootcamp.qa.apps.QaConfig", + "bootcamp.search.apps.SearchConfig", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS -FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" # MIGRATIONS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules -MIGRATION_MODULES = { - 'sites': 'bootcamp.contrib.sites.migrations' -} +MIGRATION_MODULES = {"sites": "bootcamp.contrib.sites.migrations"} # AUTHENTICATION # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", ] # https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model -AUTH_USER_MODEL = 'users.User' +AUTH_USER_MODEL = "users.User" # https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url -LOGIN_REDIRECT_URL = 'news:list' +LOGIN_REDIRECT_URL = "news:list" # https://docs.djangoproject.com/en/dev/ref/settings/#login-url -LOGIN_URL = 'account_login' +LOGIN_URL = "account_login" # PASSWORDS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers PASSWORD_HASHERS = [ # https://docs.djangoproject.com/en/dev/topics/auth/passwords/#using-argon2-with-django - 'django.contrib.auth.hashers.Argon2PasswordHasher', - 'django.contrib.auth.hashers.PBKDF2PasswordHasher', - 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', - 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', - 'django.contrib.auth.hashers.BCryptPasswordHasher', + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", + "django.contrib.auth.hashers.BCryptPasswordHasher", ] # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] # MIDDLEWARE # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#middleware MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] # STATIC # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#static-root -STATIC_ROOT = str(ROOT_DIR('staticfiles')) +STATIC_ROOT = str(ROOT_DIR("staticfiles")) # https://docs.djangoproject.com/en/dev/ref/settings/#static-url -STATIC_URL = '/static/' +STATIC_URL = "/static/" # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS -STATICFILES_DIRS = [ - str(APPS_DIR.path('static')), -] +STATICFILES_DIRS = [str(APPS_DIR.path("static"))] # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders STATICFILES_FINDERS = [ - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] # MEDIA # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#media-root -MEDIA_ROOT = str(ROOT_DIR('media')) +MEDIA_ROOT = str(ROOT_DIR("media")) # https://docs.djangoproject.com/en/dev/ref/settings/#media-url -MEDIA_URL = '/media/' +MEDIA_URL = "/media/" # TEMPLATES # ------------------------------------------------------------------------------ @@ -183,53 +173,51 @@ TEMPLATES = [ { # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND - 'BACKEND': 'django.template.backends.django.DjangoTemplates', + "BACKEND": "django.template.backends.django.DjangoTemplates", # https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs - 'DIRS': [ - str(APPS_DIR.path('templates')), - ], - 'OPTIONS': { + "DIRS": [str(APPS_DIR.path("templates"))], + "OPTIONS": { # https://docs.djangoproject.com/en/dev/ref/settings/#template-debug - 'debug': DEBUG, + "debug": DEBUG, # https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types - 'loaders': [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", ], # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", ], }, - }, + } ] # http://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs -CRISPY_TEMPLATE_PACK = 'bootstrap4' +CRISPY_TEMPLATE_PACK = "bootstrap4" # FIXTURES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs -FIXTURE_DIRS = ( - str(APPS_DIR.path('fixtures')), -) +FIXTURE_DIRS = (str(APPS_DIR.path("fixtures")),) # EMAIL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend -EMAIL_BACKEND = env('EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend') +EMAIL_BACKEND = env( + "EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend" +) # ADMIN # ------------------------------------------------------------------------------ # Django Admin URL regex. -ADMIN_URL = r'^admin/' +ADMIN_URL = r"^admin/" # https://docs.djangoproject.com/en/dev/ref/settings/#admins # ADMINS = [ # ("""Vitor Freitas""", 'vitor-freitas@example.com'), @@ -240,17 +228,17 @@ # django-allauth # ------------------------------------------------------------------------------ -ACCOUNT_ALLOW_REGISTRATION = env.bool('ACCOUNT_ALLOW_REGISTRATION', True) +ACCOUNT_ALLOW_REGISTRATION = env.bool("ACCOUNT_ALLOW_REGISTRATION", True) # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_AUTHENTICATION_METHOD = 'username' +ACCOUNT_AUTHENTICATION_METHOD = "username" # https://django-allauth.readthedocs.io/en/latest/configuration.html ACCOUNT_EMAIL_REQUIRED = True # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_EMAIL_VERIFICATION = 'mandatory' +ACCOUNT_EMAIL_VERIFICATION = "mandatory" # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_ADAPTER = 'bootcamp.users.adapters.AccountAdapter' +ACCOUNT_ADAPTER = "bootcamp.users.adapters.AccountAdapter" # https://django-allauth.readthedocs.io/en/latest/configuration.html -SOCIALACCOUNT_ADAPTER = 'bootcamp.users.adapters.SocialAccountAdapter' +SOCIALACCOUNT_ADAPTER = "bootcamp.users.adapters.SocialAccountAdapter" # Your stuff... @@ -260,18 +248,14 @@ REDIS_URL = f'{env("REDIS_URL", default="redis://127.0.0.1:6379")}/{0}' # django-channels setup -ASGI_APPLICATION = 'config.routing.application' +ASGI_APPLICATION = "config.routing.application" CHANNEL_LAYERS = { - 'default': { - 'BACKEND': 'channels_redis.core.RedisChannelLayer', - 'CONFIG': { - 'hosts': [REDIS_URL, ], - }, + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": {"hosts": [REDIS_URL]}, } } # GraphQL settings -GRAPHENE = { - 'SCHEMA': 'bootcamp.schema.schema' -} +GRAPHENE = {"SCHEMA": "config.schema.schema"} diff --git a/config/settings/local.py b/config/settings/local.py index c269aeb68..c29246d1f 100755 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -8,65 +8,65 @@ # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = env.bool('DEBUG', default=True) +DEBUG = env.bool("DEBUG", default=True) # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key -SECRET_KEY = env('SECRET_KEY', default='fOqtAorZrVqWYbuMPOcZnTzw2D5bKeHGpXUwCaNBnvFUmO1njCQZGz05x1BhDG0E') +SECRET_KEY = env( + "SECRET_KEY", + default="fOqtAorZrVqWYbuMPOcZnTzw2D5bKeHGpXUwCaNBnvFUmO1njCQZGz05x1BhDG0E", +) # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = [ - "localhost", - "0.0.0.0", - "127.0.0.1", -] +ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] # CACHES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#caches CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': '' + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "", } } # TEMPLATES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#templates -TEMPLATES[0]['OPTIONS']['debug'] = DEBUG # noqa F405 +TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG # noqa F405 # EMAIL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend -EMAIL_BACKEND = env('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend') +EMAIL_BACKEND = env( + "EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend" +) # https://docs.djangoproject.com/en/dev/ref/settings/#email-host -EMAIL_HOST = 'localhost' +EMAIL_HOST = "localhost" # https://docs.djangoproject.com/en/dev/ref/settings/#email-port EMAIL_PORT = 1025 # django-debug-toolbar # ------------------------------------------------------------------------------ # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites -INSTALLED_APPS += ['debug_toolbar'] # noqa F405 +INSTALLED_APPS += ["debug_toolbar"] # noqa F405 # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware -MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] # noqa F405 +MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa F405 # https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config DEBUG_TOOLBAR_CONFIG = { - 'DISABLE_PANELS': [ - 'debug_toolbar.panels.redirects.RedirectsPanel', - ], - 'SHOW_TEMPLATE_CONTEXT': True, + "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], + "SHOW_TEMPLATE_CONTEXT": True, } # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips -INTERNAL_IPS = ['127.0.0.1', '10.0.2.2'] +INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] import socket import os -if os.environ.get('USE_DOCKER') == 'yes': + +if os.environ.get("USE_DOCKER") == "yes": hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) - INTERNAL_IPS += [ip[:-1] + '1' for ip in ips] + INTERNAL_IPS += [ip[:-1] + "1" for ip in ips] # django-extensions # ------------------------------------------------------------------------------ # https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration -INSTALLED_APPS += ['django_extensions'] # noqa F405 +INSTALLED_APPS += ["django_extensions"] # noqa F405 # Your stuff... # ------------------------------------------------------------------------------ diff --git a/config/settings/production.py b/config/settings/production.py index fed6663bb..659eb800f 100755 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -6,37 +6,39 @@ # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key -SECRET_KEY = env('SECRET_KEY') +SECRET_KEY = env("SECRET_KEY") # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['vitor@freitas.com trybootcamp.vitorfs.com']) +ALLOWED_HOSTS = env.list( + "ALLOWED_HOSTS", default=["vitor@freitas.com trybootcamp.vitorfs.com"] +) # DATABASES # ------------------------------------------------------------------------------ -DATABASES['default'] = env.db('DATABASE_URL') # noqa F405 -DATABASES['default']['ATOMIC_REQUESTS'] = True # noqa F405 -DATABASES['default']['CONN_MAX_AGE'] = env.int('CONN_MAX_AGE', default=60) # noqa F405 +DATABASES["default"] = env.db("DATABASE_URL") # noqa F405 +DATABASES["default"]["ATOMIC_REQUESTS"] = True # noqa F405 +DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) # noqa F405 # CACHES # ------------------------------------------------------------------------------ CACHES = { - 'default': { - 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': REDIS_URL, - 'OPTIONS': { - 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", # Mimicing memcache behavior. # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior - 'IGNORE_EXCEPTIONS': True, - } + "IGNORE_EXCEPTIONS": True, + }, } } # SECURITY # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect -SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=True) +SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True) # https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure SESSION_COOKIE_SECURE = True # https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly @@ -50,26 +52,28 @@ # TODO: set this to 60 seconds first and then to 518400 once you prove the former works SECURE_HSTS_SECONDS = 60 # https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-include-subdomains -SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool('SECURE_HSTS_INCLUDE_SUBDOMAINS', default=True) +SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool( + "SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True +) # https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-preload -SECURE_HSTS_PRELOAD = env.bool('SECURE_HSTS_PRELOAD', default=True) +SECURE_HSTS_PRELOAD = env.bool("SECURE_HSTS_PRELOAD", default=True) # https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff -SECURE_CONTENT_TYPE_NOSNIFF = env.bool('SECURE_CONTENT_TYPE_NOSNIFF', default=True) +SECURE_CONTENT_TYPE_NOSNIFF = env.bool("SECURE_CONTENT_TYPE_NOSNIFF", default=True) # https://docs.djangoproject.com/en/dev/ref/settings/#secure-browser-xss-filter SECURE_BROWSER_XSS_FILTER = True # https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options -X_FRAME_OPTIONS = 'DENY' +X_FRAME_OPTIONS = "DENY" # STORAGES # ------------------------------------------------------------------------------ # https://django-storages.readthedocs.io/en/latest/#installation -INSTALLED_APPS += ['storages'] # noqa F405 +INSTALLED_APPS += ["storages"] # noqa F405 # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID') +AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID") # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY') +AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY") # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME') +AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings AWS_AUTO_CREATE_BUCKET = True # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings @@ -78,127 +82,124 @@ _AWS_EXPIRY = 60 * 60 * 24 * 7 # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings AWS_S3_OBJECT_PARAMETERS = { - 'CacheControl': f'max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate', + "CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate" } # STATIC # ------------------------ -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" # MEDIA # ------------------------------------------------------------------------------ -DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' -MEDIA_URL = f'https://s3.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}/' +DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" +MEDIA_URL = f"https://s3.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}/" # TEMPLATES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#templates -TEMPLATES[0]['OPTIONS']['loaders'] = [ # noqa F405 +TEMPLATES[0]["OPTIONS"]["loaders"] = [ # noqa F405 ( - 'django.template.loaders.cached.Loader', + "django.template.loaders.cached.Loader", [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - ] - ), + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + ) ] # EMAIL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email DEFAULT_FROM_EMAIL = env( - 'DEFAULT_FROM_EMAIL', - default='Bootcamp ' + "DEFAULT_FROM_EMAIL", + default="Bootcamp ", ) # https://docs.djangoproject.com/en/dev/ref/settings/#server-email -SERVER_EMAIL = env('SERVER_EMAIL', default=DEFAULT_FROM_EMAIL) +SERVER_EMAIL = env("SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) # https://docs.djangoproject.com/en/dev/ref/settings/#email-subject-prefix -EMAIL_SUBJECT_PREFIX = env('EMAIL_SUBJECT_PREFIX', default='[Bootcamp]') +EMAIL_SUBJECT_PREFIX = env("EMAIL_SUBJECT_PREFIX", default="[Bootcamp]") # ADMIN # ------------------------------------------------------------------------------ # Django Admin URL regex. -ADMIN_URL = env('ADMIN_URL') +ADMIN_URL = env("ADMIN_URL") # Anymail (Mailgun) # ------------------------------------------------------------------------------ # https://anymail.readthedocs.io/en/stable/installation/#installing-anymail -INSTALLED_APPS += ['anymail'] # noqa F405 -EMAIL_BACKEND = 'anymail.backends.mailgun.EmailBackend' +INSTALLED_APPS += ["anymail"] # noqa F405 +EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference ANYMAIL = { - 'MAILGUN_API_KEY': env('MAILGUN_API_KEY'), - 'MAILGUN_SENDER_DOMAIN': env('MAILGUN_SENDER_DOMAIN') + "MAILGUN_API_KEY": env("MAILGUN_API_KEY"), + "MAILGUN_SENDER_DOMAIN": env("MAILGUN_SENDER_DOMAIN"), } # WhiteNoise # ------------------------------------------------------------------------------ # http://whitenoise.evans.io/en/latest/django.html#enable-whitenoise -MIDDLEWARE = ['whitenoise.middleware.WhiteNoiseMiddleware'] + MIDDLEWARE # noqa F405 +MIDDLEWARE = ["whitenoise.middleware.WhiteNoiseMiddleware"] + MIDDLEWARE # noqa F405 # raven # ------------------------------------------------------------------------------ # https://docs.sentry.io/clients/python/integrations/django/ -INSTALLED_APPS += ['raven.contrib.django.raven_compat'] # noqa F405 -MIDDLEWARE = ['raven.contrib.django.raven_compat.middleware.SentryResponseErrorIdMiddleware'] + MIDDLEWARE +INSTALLED_APPS += ["raven.contrib.django.raven_compat"] # noqa F405 +MIDDLEWARE = [ + "raven.contrib.django.raven_compat.middleware.SentryResponseErrorIdMiddleware" +] + MIDDLEWARE # Sentry # ------------------------------------------------------------------------------ -SENTRY_DSN = env('SENTRY_DSN') -SENTRY_CLIENT = env('SENTRY_CLIENT', default='raven.contrib.django.raven_compat.DjangoClient') +SENTRY_DSN = env("SENTRY_DSN") +SENTRY_CLIENT = env( + "SENTRY_CLIENT", default="raven.contrib.django.raven_compat.DjangoClient" +) LOGGING = { - 'version': 1, - 'disable_existing_loggers': True, - 'root': { - 'level': 'WARNING', - 'handlers': ['sentry'], + "version": 1, + "disable_existing_loggers": True, + "root": {"level": "WARNING", "handlers": ["sentry"]}, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s " + "%(process)d %(thread)d %(message)s" + } }, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s ' - '%(process)d %(thread)d %(message)s' + "handlers": { + "sentry": { + "level": "ERROR", + "class": "raven.contrib.django.raven_compat.handlers.SentryHandler", }, - }, - 'handlers': { - 'sentry': { - 'level': 'ERROR', - 'class': 'raven.contrib.django.raven_compat.handlers.SentryHandler', + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' - } }, - 'loggers': { - 'django.db.backends': { - 'level': 'ERROR', - 'handlers': ['console'], - 'propagate': False, - }, - 'raven': { - 'level': 'DEBUG', - 'handlers': ['console'], - 'propagate': False, + "loggers": { + "django.db.backends": { + "level": "ERROR", + "handlers": ["console"], + "propagate": False, }, - 'sentry.errors': { - 'level': 'DEBUG', - 'handlers': ['console'], - 'propagate': False, + "raven": {"level": "DEBUG", "handlers": ["console"], "propagate": False}, + "sentry.errors": { + "level": "DEBUG", + "handlers": ["console"], + "propagate": False, }, - 'django.security.DisallowedHost': { - 'level': 'ERROR', - 'handlers': ['console', 'sentry'], - 'propagate': False, + "django.security.DisallowedHost": { + "level": "ERROR", + "handlers": ["console", "sentry"], + "propagate": False, }, }, } -SENTRY_CELERY_LOGLEVEL = env.int('SENTRY_LOG_LEVEL', logging.INFO) +SENTRY_CELERY_LOGLEVEL = env.int("SENTRY_LOG_LEVEL", logging.INFO) RAVEN_CONFIG = { - 'CELERY_LOGLEVEL': env.int('SENTRY_LOG_LEVEL', logging.INFO), - 'DSN': SENTRY_DSN + "CELERY_LOGLEVEL": env.int("SENTRY_LOG_LEVEL", logging.INFO), + "DSN": SENTRY_DSN, } # Your stuff... diff --git a/config/settings/test.py b/config/settings/test.py index 0f046befa..04b077503 100755 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -10,47 +10,45 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#debug DEBUG = False # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key -SECRET_KEY = env('SECRET_KEY', default='0wG2N60LqkDwM0Vi42p63bTekW3ac7Jt9w140') +SECRET_KEY = env("SECRET_KEY", default="0wG2N60LqkDwM0Vi42p63bTekW3ac7Jt9w140") # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner -TEST_RUNNER = 'django.test.runner.DiscoverRunner' +TEST_RUNNER = "django.test.runner.DiscoverRunner" # CACHES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#caches CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': '' + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "", } } # PASSWORDS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers -PASSWORD_HASHERS = [ - 'django.contrib.auth.hashers.MD5PasswordHasher', -] +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] # TEMPLATES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#templates -TEMPLATES[0]['OPTIONS']['debug'] = DEBUG # noqa F405 -TEMPLATES[0]['OPTIONS']['loaders'] = [ # noqa F405 +TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG # noqa F405 +TEMPLATES[0]["OPTIONS"]["loaders"] = [ # noqa F405 ( - 'django.template.loaders.cached.Loader', + "django.template.loaders.cached.Loader", [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", ], - ), + ) ] # EMAIL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend -EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" # https://docs.djangoproject.com/en/dev/ref/settings/#email-host -EMAIL_HOST = 'localhost' +EMAIL_HOST = "localhost" # https://docs.djangoproject.com/en/dev/ref/settings/#email-port EMAIL_PORT = 1025 diff --git a/config/urls.py b/config/urls.py index 2debc405c..1787dfb9a 100755 --- a/config/urls.py +++ b/config/urls.py @@ -8,42 +8,54 @@ from graphene_django.views import GraphQLView urlpatterns = [ - url(r'^$', - TemplateView.as_view(template_name='pages/home.html'), name='home'), - url(r'^about/$', - TemplateView.as_view(template_name='pages/about.html'), name='about'), + url(r"^$", TemplateView.as_view(template_name="pages/home.html"), name="home"), + url( + r"^about/$", + TemplateView.as_view(template_name="pages/about.html"), + name="about", + ), # Django Admin, use {% url 'admin:index' %} url(settings.ADMIN_URL, admin.site.urls), # User management - url(r'^users/', include('bootcamp.users.urls', namespace='users')), - url(r'^accounts/', include('allauth.urls')), + url(r"^users/", include("bootcamp.users.urls", namespace="users")), + url(r"^accounts/", include("allauth.urls")), # Third party apps here - url(r'^comments/', include('django_comments.urls')), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), - url(r'^markdownx/', include('markdownx.urls')), + url(r"^comments/", include("django_comments.urls")), + url(r"^graphql", GraphQLView.as_view(graphiql=True)), + url(r"^markdownx/", include("markdownx.urls")), # Local apps here - url(r'^notifications/', - include('bootcamp.notifications.urls', namespace='notifications')), - url(r'^articles/', - include('bootcamp.articles.urls', namespace='articles')), - url(r'^news/', include('bootcamp.news.urls', namespace='news')), - url(r'^messages/', - include('bootcamp.messager.urls', namespace='messager')), - url(r'^qa/', include('bootcamp.qa.urls', namespace='qa')), - url(r'^search/', include('bootcamp.search.urls', namespace='search')), - + url( + r"^notifications/", + include("bootcamp.notifications.urls", namespace="notifications"), + ), + url(r"^articles/", include("bootcamp.articles.urls", namespace="articles")), + url(r"^news/", include("bootcamp.news.urls", namespace="news")), + url(r"^messages/", include("bootcamp.messager.urls", namespace="messager")), + url(r"^qa/", include("bootcamp.qa.urls", namespace="qa")), + url(r"^search/", include("bootcamp.search.urls", namespace="search")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: # This allows the error pages to be debugged during development urlpatterns += [ - url(r'^400/$', default_views.bad_request, kwargs={'exception': Exception('Bad Request!')}), - url(r'^403/$', default_views.permission_denied, kwargs={'exception': Exception('Permission Denied')}), - url(r'^404/$', default_views.page_not_found, kwargs={'exception': Exception('Page not Found')}), - url(r'^500/$', default_views.server_error), + url( + r"^400/$", + default_views.bad_request, + kwargs={"exception": Exception("Bad Request!")}, + ), + url( + r"^403/$", + default_views.permission_denied, + kwargs={"exception": Exception("Permission Denied")}, + ), + url( + r"^404/$", + default_views.page_not_found, + kwargs={"exception": Exception("Page not Found")}, + ), + url(r"^500/$", default_views.server_error), ] - if 'debug_toolbar' in settings.INSTALLED_APPS: + if "debug_toolbar" in settings.INSTALLED_APPS: import debug_toolbar - urlpatterns = [ - url(r'^__debug__/', include(debug_toolbar.urls)), - ] + urlpatterns + + urlpatterns = [url(r"^__debug__/", include(debug_toolbar.urls))] + urlpatterns diff --git a/config/wsgi.py b/config/wsgi.py index ac31de273..b0edf189e 100755 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -20,11 +20,12 @@ # This allows easy placement of apps within the interior # bootcamp directory. -app_path = os.path.abspath(os.path.join( - os.path.dirname(os.path.abspath(__file__)), os.pardir)) -sys.path.append(os.path.join(app_path, 'bootcamp')) +app_path = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) +) +sys.path.append(os.path.join(app_path, "bootcamp")) -if os.environ.get('DJANGO_SETTINGS_MODULE') == 'config.settings.production': +if os.environ.get("DJANGO_SETTINGS_MODULE") == "config.settings.production": from raven.contrib.django.raven_compat.middleware.wsgi import Sentry # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks @@ -37,7 +38,7 @@ # file. This includes Django's development server, if the WSGI_APPLICATION # setting points here. application = get_wsgi_application() -if os.environ.get('DJANGO_SETTINGS_MODULE') == 'config.settings.production': +if os.environ.get("DJANGO_SETTINGS_MODULE") == "config.settings.production": application = Sentry(application) # Apply WSGI middleware here. # from helloworld.wsgi import HelloWorldApplication diff --git a/dev.yml b/dev.yml index c64cbefab..4d00f8197 100755 --- a/dev.yml +++ b/dev.yml @@ -6,7 +6,7 @@ volumes: services: postgres: - image: postgres:10 + image: postgres:11-alpine volumes: - postgres_data:/var/lib/postgresql/data env_file: .env @@ -14,6 +14,6 @@ services: - '5432:5432' redis: - image: redis:4.0 + image: redis:5.0 ports: - '6379:6379' diff --git a/local.yml b/local.yml index dc66f7e93..9e7e710c2 100755 --- a/local.yml +++ b/local.yml @@ -29,6 +29,6 @@ services: env_file: .env redis: - image: redis:4.0 + image: redis:5.0 ports: - '6379:6379' diff --git a/manage.py b/manage.py index 06ab82d75..4bc215c33 100755 --- a/manage.py +++ b/manage.py @@ -1,8 +1,8 @@ import os import sys -if __name__ == '__main__': - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.local') +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") try: from django.core.management import execute_from_command_line @@ -25,6 +25,6 @@ # This allows easy placement of apps within the inner bootcamp directory. current_path = os.path.dirname(os.path.abspath(__file__)) - sys.path.append(os.path.join(current_path, 'bootcamp')) + sys.path.append(os.path.join(current_path, "bootcamp")) execute_from_command_line(sys.argv) diff --git a/production.yml b/production.yml index aa68ff9de..491f7cb75 100755 --- a/production.yml +++ b/production.yml @@ -48,4 +48,4 @@ services: - "0.0.0.0:443:443" redis: - image: redis:4.0 + image: redis:5.0 diff --git a/requirements/base.txt b/requirements/base.txt index bc90885e1..a106ca342 100755 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,30 +1,33 @@ # Python # ------------------------------------------------------------------------------ -argon2-cffi>=18.1.0 # https://github.com/hynek/argon2_cffi -awesome-slugify>=1.6.5 # https://github.com/dimka665/awesome-slugify -Pillow>=5.2.0 # https://github.com/python-pillow/Pillow -psycopg2>=2.7.5 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 -pytz>=2018.5 # https://github.com/stub42/pytz -redis>=2.10.5 # https://github.com/antirez/redis -whitenoise>=3.3.1 # https://github.com/evansd/whitenoise +argon2-cffi # https://github.com/hynek/argon2_cffi +python-slugify # https://github.com/un33k/python-slugify +Pillow # https://github.com/python-pillow/Pillow +pytz # https://github.com/stub42/pytz +redis # https://github.com/antirez/redis +whitenoise # https://github.com/evansd/whitenoise # Django # ------------------------------------------------------------------------------ -django>=2.0.7 # pyup: < 2.1 # https://www.djangoproject.com/ -django-allauth>=0.35.0 # https://github.com/pennersr/django-allauth -django-contrib-comments>=1.8.0 # https://github.com/django/django-contrib-comments -django-crispy-forms>=1.7.2 # https://github.com/django-crispy-forms/django-crispy-forms -django-environ>=0.4.4 # https://github.com/joke2k/django-environ -django-markdownx>=2.0.23 # https://github.com/neutronX/django-markdownx -django-redis>=4.9.0 # https://github.com/niwinz/django-redis -django-taggit>=0.22.2 # https://github.com/alex/django-taggit -sorl-thumbnail>=12.4.1 # https://github.com/jazzband/sorl-thumbnail +django # pyup: < 2.1 # https://www.djangoproject.com/ +django-allauth # https://github.com/pennersr/django-allauth +django-contrib-comments # https://github.com/django/django-contrib-comments +django-crispy-forms # https://github.com/django-crispy-forms/django-crispy-forms +django-environ # https://github.com/joke2k/django-environ +django-markdownx # https://github.com/neutronX/django-markdownx +django-redis # https://github.com/niwinz/django-redis +django-taggit # https://github.com/alex/django-taggit +sorl-thumbnail # https://github.com/jazzband/sorl-thumbnail # Channels # ------------------------------------------------------------------------------ -channels>=2.1.1 # https://github.com/django/channels -channels-redis>=2.2.1 # https://github.com/django/channels_redis +channels # https://github.com/django/channels +channels-redis # https://github.com/django/channels_redis # GraphQL API # ------------------------------------------------------------------------------ -graphene-django>=2.0 # https://github.com/graphql-python/graphene-django +graphene-django # https://github.com/graphql-python/graphene-django + +# Open Graph Protocol +# ------------------------------------------------------------------------------ +beautifulsoup4 # https://www.crummy.com/software/BeautifulSoup/ diff --git a/requirements/local.txt b/requirements/local.txt index e5b864716..bd322ceb2 100755 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -1,27 +1,29 @@ -r ./base.txt -ipdb>=0.11 # https://github.com/gotcha/ipdb -Sphinx>=1.7.1 # https://github.com/sphinx-doc/sphinx -Werkzeug>=0.14.1 # https://github.com/pallets/werkzeug +ipdb # https://github.com/gotcha/ipdb +Werkzeug # https://github.com/pallets/werkzeug +psycopg2-binary # https://github.com/psycopg/psycopg2 # Testing # ------------------------------------------------------------------------------ -pytest>=3.4.2 # https://github.com/pytest-dev/pytest -pytest-cov>=2.5.1 # https://github.com/pytest-dev/pytest-cov -pytest-sugar>=0.9.1 # https://github.com/Frozenball/pytest-sugar +pytest # https://github.com/pytest-dev/pytest +pytest-cov # https://github.com/pytest-dev/pytest-cov +pytest-sugar # https://github.com/Frozenball/pytest-sugar +pytest-django # https://github.com/pytest-dev/pytest-django # Code quality # ------------------------------------------------------------------------------ -coverage>=4.5.1 # https://github.com/nedbat/coveragepy -flake8>=3.5.0 # https://github.com/PyCQA/flake8 -pylint-common>=0.2.5 # https://github.com/landscapeio/pylint-common -pylint-django>=0.9.0 # https://github.com/PyCQA/pylint-django -pylint>=1.8 # https://github.com/PyCQA/pylint +coverage # https://github.com/nedbat/coveragepy +bandit # https://github.com/PyCQA/bandit +pylint-common # https://github.com/landscapeio/pylint-common +pylint-django # https://github.com/PyCQA/pylint-django +pylint # https://github.com/PyCQA/pylint +black # https://github.com/psf/black +flake8 # https://github.com/pycqa/flake8 # Django # ------------------------------------------------------------------------------ -django-coverage-plugin>=1.5.0 # https://github.com/nedbat/django_coverage_plugin -django-debug-toolbar>=1.9.1 # https://github.com/jazzband/django-debug-toolbar -django-extensions>=2.0.5 # https://github.com/django-extensions/django-extensions -django-test-plus>=1.0.22 # https://github.com/revsys/django-test-plus -pytest-django>=3.1.2 # https://github.com/pytest-dev/pytest-django +django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin +django-debug-toolbar # https://github.com/jazzband/django-debug-toolbar +django-extensions # https://github.com/django-extensions/django-extensions +django-test-plus # https://github.com/revsys/django-test-plus diff --git a/requirements/production.txt b/requirements/production.txt index 53acf8c37..174bef27b 100755 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -2,11 +2,12 @@ -r base.txt -boto3>=1.6.2 # pyup: update minor # https://github.com/boto/boto3 -gevent>=1.2.2 # https://github.com/gevent/gevent -raven>=6.6.0 # https://github.com/getsentry/raven-python +boto3 # pyup: update minor # https://github.com/boto/boto3 +gevent # https://github.com/gevent/gevent +raven # https://github.com/getsentry/raven-python +psycopg2 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 # Django # ------------------------------------------------------------------------------ -django-anymail>=2.0 # https://github.com/anymail/django-anymail -django-storages>=1.6.5 # https://github.com/jschneier/django-storages +django-anymail # https://github.com/anymail/django-anymail +django-storages # https://github.com/jschneier/django-storages diff --git a/setup.cfg b/setup.cfg index 6df81d26f..8fce85910 100755 --- a/setup.cfg +++ b/setup.cfg @@ -58,7 +58,7 @@ confidence= # either give multiple identifier separated by comma (,) or put this option # multiple time. See also the "--disable" option for examples. disable=all -enable=import-error,import-self,reimported,wildcard-import,misplaced-future,deprecated-module,unpacking-non-sequence,invalid-all-object,undefined-all-variable,used-before-assignment,cell-var-from-loop,global-variable-undefined,redefine-in-handler,unused-import,unused-wildcard-import,global-variable-not-assigned,undefined-loop-variable,global-statement,global-at-module-level,bad-open-mode,redundant-unittest-assert,boolean-datetimedeprecated-method,anomalous-unicode-escape-in-string,anomalous-backslash-in-string,not-in-loop,continue-in-finally,abstract-class-instantiated,star-needs-assignment-target,duplicate-argument-name,return-in-init,too-many-star-expressions,nonlocal-and-global,return-outside-function,return-arg-in-generator,invalid-star-assignment-target,bad-reversed-sequence,nonexistent-operator,yield-outside-function,init-is-generator,nonlocal-without-binding,lost-exception,assert-on-tuple,dangerous-default-value,duplicate-key,useless-else-on-loopexpression-not-assigned,confusing-with-statement,unnecessary-lambda,pointless-statement,pointless-string-statement,unnecessary-pass,unreachable,eval-used,exec-used,using-constant-test,bad-super-call,missing-super-argument,slots-on-old-class,super-on-old-class,property-on-old-class,not-an-iterable,not-a-mapping,format-needs-mapping,truncated-format-string,missing-format-string-key,mixed-format-string,too-few-format-args,bad-str-strip-call,too-many-format-args,bad-format-character,format-combined-specification,bad-format-string-key,bad-format-string,missing-format-attribute,missing-format-argument-key,unused-format-string-argumentunused-format-string-key,invalid-format-index,bad-indentation,mixed-indentation,unnecessary-semicolon,lowercase-l-suffix,invalid-encoded-data,unpacking-in-except,import-star-module-level,long-suffix,old-octal-literal,old-ne-operator,backtick,old-raise-syntax,metaclass-assignment,next-method-called,dict-iter-method,dict-view-method,indexing-exception,raising-string,using-cmp-argument,cmp-method,coerce-method,delslice-method,getslice-method,hex-method,nonzero-method,t-method,setslice-method,old-division,logging-format-truncated,logging-too-few-args,logging-too-many-args,logging-unsupported-format,logging-format-interpolation,invalid-unary-operand-type,unsupported-binary-operation,not-callable,redundant-keyword-arg,assignment-from-no-return,assignment-from-none,not-context-manager,repeated-keyword,missing-kwoa,no-value-for-parameter,invalid-sequence-index,invalid-slice-index,unexpected-keyword-arg,unsupported-membership-test,unsubscriptable-object,access-member-before-definition,method-hidden,assigning-non-slot,duplicate-bases,inconsistent-mro,inherit-non-class,invalid-slots,invalid-slots-object,no-method-argument,no-self-argument,unexpected-special-method-signature,non-iterator-returned,arguments-differ,signature-differs,bad-staticmethod-argument,non-parent-init-called,bad-except-order,catching-non-exception,bad-exception-context,notimplemented-raised,raising-bad-type,raising-non-exception,misplaced-bare-raise,duplicate-except,broad-except,nonstandard-exception,binary-op-exception,bare-except,not-async-context-manager,yield-inside-async-function +enable=import-error,import-self,reimported,wildcard-import,misplaced-future,deprecated-module,unpacking-non-sequence,invalid-all-object,undefined-all-variable,used-before-assignment,cell-var-from-loop,global-variable-undefined,redefine-in-handler,unused-import,unused-wildcard-import,global-variable-not-assigned,undefined-loop-variable,global-statement,global-at-module-level,bad-open-mode,redundant-unittest-assert,boolean-datetimedeprecated-method,anomalous-unicode-escape-in-string,anomalous-backslash-in-string,not-in-loop,continue-in-finally,abstract-class-instantiated,star-needs-assignment-target,duplicate-argument-name,return-in-init,too-many-star-expressions,nonlocal-and-global,return-outside-function,return-arg-in-generator,invalid-star-assignment-target,bad-reversed-sequence,nonexistent-operator,yield-outside-function,init-is-generator,nonlocal-without-binding,lost-exception,assert-on-tuple,dangerous-default-value,duplicate-key,useless-else-on-loopexpression-not-assigned,confusing-with-statement,unnecessary-lambda,pointless-statement,pointless-string-statement,unnecessary-pass,unreachable,eval-used,exec-used,using-constant-test,bad-super-call,missing-super-argument,slots-on-old-class,super-on-old-class,property-on-old-class,not-an-iterable,not-a-mapping,format-needs-mapping,truncated-format-string,missing-format-string-key,mixed-format-string,too-few-format-args,bad-str-strip-call,too-many-format-args,bad-format-character,format-combined-specification,bad-format-string-key,bad-format-string,missing-format-attribute,missing-format-argument-key,unused-format-string-argumentunused-format-string-key,invalid-format-index,bad-indentation,mixed-indentation,unnecessary-semicolon,lowercase-l-suffix,invalid-encoded-data,unpacking-in-except,import-star-module-level,long-suffix,old-octal-literal,old-ne-operator,backtick,old-raise-syntax,metaclass-assignment,next-method-called,dict-iter-method,dict-view-method,indexing-exception,raising-string,using-cmp-argument,cmp-method,coerce-method,delslice-method,getslice-method,hex-method,nonzero-method,t-method,setslice-method,old-division,logging-format-truncated,logging-too-few-args,logging-too-many-args,logging-unsupported-format,logging-format-interpolation,invalid-unary-operand-type,unsupported-binary-operation,not-callable,redundant-keyword-arg,assignment-from-no-return,assignment-from-none,not-context-manager,repeated-keyword,missing-kwoa,no-value-for-parameter,invalid-sequence-index,invalid-slice-index,unexpected-keyword-arg,unsupported-membership-test,unsubscriptable-object,access-member-before-definition,method-hidden,assigning-non-slot,duplicate-bases,inconsistent-mro,inherit-non-class,invalid-slots,invalid-slots-object,no-method-argument,no-self-argument,unexpected-special-method-signature,non-iterator-returned,signature-differs,bad-staticmethod-argument,non-parent-init-called,bad-except-order,catching-non-exception,bad-exception-context,notimplemented-raised,raising-bad-type,raising-non-exception,misplaced-bare-raise,duplicate-except,broad-except,nonstandard-exception,binary-op-exception,bare-except,not-async-context-manager,yield-inside-async-function # Needs investigation: # abstract-method (might be indicating a bug? probably not though) # protected-access (requires some refactoring)