├── .github └── workflows │ └── bootcamp.yml ├── .gitignore ├── LICENSE ├── README.md ├── bootcamp ├── __init__.py ├── activities │ ├── __init__.py │ ├── admin.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── static │ │ ├── css │ │ │ └── notifications.css │ │ └── js │ │ │ └── notifications.js │ ├── templates │ │ └── activities │ │ │ ├── last_notifications.html │ │ │ └── notifications.html │ ├── tests.py │ ├── urls.py │ └── views.py ├── articles │ ├── __init__.py │ ├── admin.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── static │ │ ├── css │ │ │ └── articles.css │ │ └── js │ │ │ └── articles.js │ ├── templates │ │ └── articles │ │ │ ├── article.html │ │ │ ├── articles.html │ │ │ ├── drafts.html │ │ │ ├── edit.html │ │ │ ├── partial_article.html │ │ │ ├── partial_article_comment.html │ │ │ ├── partial_article_comments.html │ │ │ └── write.html │ ├── tests.py │ ├── urls.py │ └── views.py ├── asgi.py ├── authentication │ ├── __init__.py │ ├── admin.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── schema.py │ ├── static │ │ └── css │ │ │ └── signup.css │ ├── templates │ │ └── auth │ │ │ └── signup.html │ ├── tests.py │ └── views.py ├── core │ ├── __init__.py │ ├── forms.py │ ├── migrations │ │ └── __init__.py │ ├── static │ │ ├── css │ │ │ ├── cover.css │ │ │ ├── network.css │ │ │ └── profile.css │ │ └── js │ │ │ └── picture.js │ ├── templates │ │ └── core │ │ │ ├── cover.html │ │ │ ├── network.html │ │ │ ├── partial_settings_menu.html │ │ │ ├── password.html │ │ │ ├── picture.html │ │ │ ├── profile.html │ │ │ └── settings.html │ ├── tests.py │ ├── urls.py │ └── views.py ├── feeds │ ├── __init__.py │ ├── admin.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── schema.py │ ├── static │ │ ├── css │ │ │ └── feeds.css │ │ └── js │ │ │ └── feeds.js │ ├── templates │ │ └── feeds │ │ │ ├── feed.html │ │ │ ├── feeds.html │ │ │ ├── partial_feed.html │ │ │ └── partial_feed_comments.html │ ├── tests.py │ ├── urls.py │ └── views.py ├── locale │ └── zh_Hans │ │ └── LC_MESSAGES │ │ └── django.po ├── messenger │ ├── __init__.py │ ├── admin.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── static │ │ ├── css │ │ │ └── messages.css │ │ └── js │ │ │ ├── check_messages.js │ │ │ ├── messages.js │ │ │ └── messages.typehead.js │ ├── templates │ │ └── messages │ │ │ ├── base_messages.html │ │ │ ├── inbox.html │ │ │ ├── includes │ │ │ ├── partial_conversations.html │ │ │ └── partial_message.html │ │ │ └── new.html │ ├── tests.py │ ├── urls.py │ └── views.py ├── middleware.py ├── questions │ ├── __init__.py │ ├── admin.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── static │ │ ├── css │ │ │ └── questions.css │ │ └── js │ │ │ └── questions.js │ ├── templates │ │ └── questions │ │ │ ├── ask.html │ │ │ ├── partial_answer.html │ │ │ ├── partial_question.html │ │ │ ├── question.html │ │ │ └── questions.html │ ├── tests.py │ ├── urls.py │ └── views.py ├── schema.py ├── search │ ├── __init__.py │ ├── static │ │ ├── css │ │ │ └── search.css │ │ └── js │ │ │ └── search.js │ ├── templates │ │ └── search │ │ │ ├── partial_articles_results.html │ │ │ ├── partial_feed_results.html │ │ │ ├── partial_questions_results.html │ │ │ ├── partial_results_menu.html │ │ │ ├── partial_users_results.html │ │ │ ├── results.html │ │ │ └── search.html │ ├── tests.py │ └── views.py ├── settings.py ├── static │ ├── css │ │ └── bootcamp.css │ ├── img │ │ ├── Jcrop.gif │ │ ├── favicon.png │ │ ├── loading.gif │ │ └── user.png │ └── js │ │ ├── bootcamp.js │ │ └── bootcamp.markdown.js ├── templates │ ├── 403.html │ ├── 404.html │ ├── 500.html │ ├── base.html │ ├── markdown_editor.html │ └── paginator.html ├── urls.py └── wsgi.py ├── manage.py └── requirements.txt /.github/workflows/bootcamp.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-python@v5 11 | with: 12 | python-version: '3.13' 13 | cache: 'pip' 14 | - name: Install dependencies 15 | run: | 16 | pip install -r requirements.txt 17 | pip install codecov ruff 18 | - name: Lint with ruff 19 | run: | 20 | ruff check bootcamp/ 21 | - name: Test with codecov 22 | env: 23 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 24 | run: | 25 | coverage run manage.py test 26 | codecov 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | .ruff_cache/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | # IPython Notebook 64 | .ipynb_checkpoints 65 | 66 | # pyenv 67 | .python-version 68 | 69 | # celery beat schedule file 70 | celerybeat-schedule 71 | 72 | # dotenv 73 | .env 74 | .env.leave 75 | 76 | # virtualenv 77 | .venv/ 78 | venv/ 79 | 80 | # Rope project settings 81 | .ropeproject 82 | 83 | # Static files 84 | media/ 85 | staticfiles/ 86 | 87 | # PyCharm 88 | .idea/ 89 | 90 | # Vim 91 | *.swp 92 | *.swa 93 | 94 | # SQLite 95 | db.sqlite3 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Vitor Freitas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bootcamp an public social network 2 | 3 | [![Bootcamp](https://github.com/qulc/bootcamp/actions/workflows/bootcamp.yml/badge.svg)](https://github.com/qulc/bootcamp/actions) 4 | [![codecov](https://codecov.io/gh/qulc/bootcamp/branch/master/graph/badge.svg)](https://codecov.io/gh/qulc/bootcamp) 5 | 6 | 7 | ### Technology Stack 8 | 9 | [![python](https://img.shields.io/badge/python-3.13-green.svg)](https://python.org) 10 | [![django](https://img.shields.io/badge/django-5.1-green.svg)](https://www.djangoproject.com/) 11 | [![graphql](https://img.shields.io/badge/graphene--django-v3.2.2-green.svg)](https://github.com/graphql-python/graphene-django) 12 | 13 | 14 | ### Basic Commands 15 | 16 | To run the tests 17 | 18 | ``` 19 | $ python manage.py test 20 | ``` 21 | 22 | To run the server 23 | 24 | ``` 25 | $ python manage.py migrate 26 | $ python manage.py runserver 27 | ``` 28 | 29 | To compile i18n messages 30 | 31 | ``` 32 | $ python manage.py makemessages -l zh_Hans 33 | $ python manage.py compilemessages -l zh_Hans 34 | ``` 35 | -------------------------------------------------------------------------------- /bootcamp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/__init__.py -------------------------------------------------------------------------------- /bootcamp/activities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/activities/__init__.py -------------------------------------------------------------------------------- /bootcamp/activities/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Activity, Notification 4 | 5 | admin.site.register(Activity) 6 | admin.site.register(Notification) 7 | -------------------------------------------------------------------------------- /bootcamp/activities/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-06 10:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('questions', '0001_initial'), 15 | ('feeds', '0001_initial'), 16 | ('articles', '0001_initial'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Notification', 22 | fields=[ 23 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('date', models.DateTimeField(auto_now_add=True)), 25 | ('notification_type', models.CharField(choices=[('L', 'Liked'), ('C', 'Commented'), ('F', 'Favorited'), ('A', 'Answered'), ('W', 'Accepted Answer'), ('E', 'Edited Article'), ('S', 'Also Commented')], max_length=1)), 26 | ('is_read', models.BooleanField(default=False)), 27 | ('answer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='questions.answer')), 28 | ('article', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='articles.article')), 29 | ('feed', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='feeds.feed')), 30 | ('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 31 | ('question', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='questions.question')), 32 | ('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 33 | ], 34 | options={ 35 | 'verbose_name': 'Notification', 36 | 'verbose_name_plural': 'Notifications', 37 | 'ordering': ('-date',), 38 | }, 39 | ), 40 | migrations.CreateModel( 41 | name='Activity', 42 | fields=[ 43 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 44 | ('activity_type', models.CharField(choices=[('F', 'Favorite'), ('L', 'Like'), ('U', 'Up Vote'), ('D', 'Down Vote')], max_length=1)), 45 | ('date', models.DateTimeField(auto_now_add=True)), 46 | ('feed', models.IntegerField(blank=True, null=True)), 47 | ('question', models.IntegerField(blank=True, null=True)), 48 | ('answer', models.IntegerField(blank=True, null=True)), 49 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 50 | ], 51 | options={ 52 | 'verbose_name': 'Activity', 53 | 'verbose_name_plural': 'Activities', 54 | }, 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /bootcamp/activities/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/activities/migrations/__init__.py -------------------------------------------------------------------------------- /bootcamp/activities/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | from django.utils.html import escape 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class Activity(models.Model): 8 | FAVORITE = 'F' 9 | LIKE = 'L' 10 | UP_VOTE = 'U' 11 | DOWN_VOTE = 'D' 12 | ACTIVITY_TYPES = ( 13 | (FAVORITE, 'Favorite'), 14 | (LIKE, 'Like'), 15 | (UP_VOTE, 'Up Vote'), 16 | (DOWN_VOTE, 'Down Vote'), 17 | ) 18 | 19 | user = models.ForeignKey(User, on_delete=models.CASCADE) 20 | activity_type = models.CharField(max_length=1, choices=ACTIVITY_TYPES) 21 | date = models.DateTimeField(auto_now_add=True) 22 | feed = models.IntegerField(null=True, blank=True) 23 | question = models.IntegerField(null=True, blank=True) 24 | answer = models.IntegerField(null=True, blank=True) 25 | 26 | class Meta: 27 | verbose_name = 'Activity' 28 | verbose_name_plural = 'Activities' 29 | 30 | def __str__(self): 31 | return self.activity_type 32 | 33 | 34 | class Notification(models.Model): 35 | LIKED = 'L' 36 | COMMENTED = 'C' 37 | FAVORITED = 'F' 38 | ANSWERED = 'A' 39 | ACCEPTED_ANSWER = 'W' 40 | EDITED_ARTICLE = 'E' 41 | ALSO_COMMENTED = 'S' 42 | 43 | NOTIFICATION_TYPES = ( 44 | (LIKED, 'Liked'), 45 | (COMMENTED, 'Commented'), 46 | (FAVORITED, 'Favorited'), 47 | (ANSWERED, 'Answered'), 48 | (ACCEPTED_ANSWER, 'Accepted Answer'), 49 | (EDITED_ARTICLE, 'Edited Article'), 50 | (ALSO_COMMENTED, 'Also Commented'), 51 | ) 52 | 53 | _LIKED_TEMPLATE = ( 54 | '{1} %s {3}' 55 | ) % _('liked your post:') 56 | _COMMENTED_TEMPLATE = ( 57 | '{1} %s {3}' 58 | ) % _('commented on your post:') 59 | _FAVORITED_TEMPLATE = ( 60 | '{1} %s {3}' 61 | ) % _('favorited your question:') 62 | _ANSWERED_TEMPLATE = ( 63 | '{1} %s {3}' 64 | ) % _('answered your question:') 65 | _ACCEPTED_ANSWER_TEMPLATE = ( 66 | '{1} %s {3}' 67 | ) % _('accepted your answer: ') 68 | _EDITED_ARTICLE_TEMPLATE = ( 69 | '{1} %s {3}' 70 | ) % _('edited your article:') 71 | _ALSO_COMMENTED_TEMPLATE = ( 72 | '{1} %s {3}' 73 | ) % _('also commentend on the post:') 74 | 75 | from_user = models.ForeignKey(User, related_name='+', on_delete=models.CASCADE) 76 | to_user = models.ForeignKey(User, related_name='+', on_delete=models.CASCADE) 77 | date = models.DateTimeField(auto_now_add=True) 78 | feed = models.ForeignKey('feeds.Feed', null=True, blank=True, on_delete=models.CASCADE) 79 | question = models.ForeignKey('questions.Question', null=True, blank=True, on_delete=models.CASCADE) 80 | answer = models.ForeignKey('questions.Answer', null=True, blank=True, on_delete=models.CASCADE) 81 | article = models.ForeignKey('articles.Article', null=True, blank=True, on_delete=models.CASCADE) 82 | notification_type = models.CharField(max_length=1, 83 | choices=NOTIFICATION_TYPES) 84 | is_read = models.BooleanField(default=False) 85 | 86 | class Meta: 87 | verbose_name = 'Notification' 88 | verbose_name_plural = 'Notifications' 89 | ordering = ('-date',) 90 | 91 | def __str__(self): 92 | if self.notification_type == self.LIKED: 93 | return self._LIKED_TEMPLATE.format( 94 | escape(self.from_user.username), 95 | escape(self.from_user.profile.get_screen_name()), 96 | self.feed.pk, 97 | escape(self.get_summary(self.feed.post)) 98 | ) 99 | elif self.notification_type == self.COMMENTED: 100 | return self._COMMENTED_TEMPLATE.format( 101 | escape(self.from_user.username), 102 | escape(self.from_user.profile.get_screen_name()), 103 | self.feed.pk, 104 | escape(self.get_summary(self.feed.post)) 105 | ) 106 | elif self.notification_type == self.FAVORITED: 107 | return self._FAVORITED_TEMPLATE.format( 108 | escape(self.from_user.username), 109 | escape(self.from_user.profile.get_screen_name()), 110 | self.question.pk, 111 | escape(self.get_summary(self.question.title)) 112 | ) 113 | elif self.notification_type == self.ANSWERED: 114 | return self._ANSWERED_TEMPLATE.format( 115 | escape(self.from_user.username), 116 | escape(self.from_user.profile.get_screen_name()), 117 | self.question.pk, 118 | escape(self.get_summary(self.question.title)) 119 | ) 120 | elif self.notification_type == self.ACCEPTED_ANSWER: 121 | return self._ACCEPTED_ANSWER_TEMPLATE.format( 122 | escape(self.from_user.username), 123 | escape(self.from_user.profile.get_screen_name()), 124 | self.answer.question.pk, 125 | escape(self.get_summary(self.answer.description)) 126 | ) 127 | elif self.notification_type == self.EDITED_ARTICLE: 128 | return self._EDITED_ARTICLE_TEMPLATE.format( 129 | escape(self.from_user.username), 130 | escape(self.from_user.profile.get_screen_name()), 131 | self.article.slug, 132 | escape(self.get_summary(self.article.title)) 133 | ) 134 | elif self.notification_type == self.ALSO_COMMENTED: 135 | return self._ALSO_COMMENTED_TEMPLATE.format( 136 | escape(self.from_user.username), 137 | escape(self.from_user.profile.get_screen_name()), 138 | self.feed.pk, 139 | escape(self.get_summary(self.feed.post)) 140 | ) 141 | 142 | return 'Ooops! Something went wrong.' 143 | 144 | def get_summary(self, value): 145 | summary_size = 50 146 | if len(value) > summary_size: 147 | return f'{value[:summary_size]}...' 148 | 149 | return value 150 | -------------------------------------------------------------------------------- /bootcamp/activities/static/css/notifications.css: -------------------------------------------------------------------------------- 1 | ul.all-notifications { 2 | padding: 0; 3 | margin-top: 1em; 4 | } 5 | 6 | ul.all-notifications li { 7 | list-style: none; 8 | border-bottom: 1px solid #eeeeee; 9 | padding: .8em 0; 10 | } 11 | 12 | ul.all-notifications li:last-child { 13 | border-bottom: none; 14 | } 15 | 16 | ul.all-notifications li small { 17 | color: #cccccc; 18 | font-size: .8em; 19 | } 20 | 21 | ul.all-notifications li p { 22 | margin: 0; 23 | } 24 | 25 | ul.all-notifications li div { 26 | margin-left: 40px; 27 | padding-left: 1em; 28 | } 29 | 30 | ul.all-notifications .user-picture { 31 | width: 40px; 32 | float: left; 33 | } -------------------------------------------------------------------------------- /bootcamp/activities/static/js/notifications.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $('#notifications').popover({html: true, content: 'Loading...', trigger: 'manual'}); 3 | 4 | $("#notifications").click(function () { 5 | if ($(".popover").is(":visible")) { 6 | $("#notifications").popover('hide'); 7 | } 8 | else { 9 | $("#notifications").popover('show'); 10 | $.ajax({ 11 | url: '/notifications/last/', 12 | beforeSend: function () { 13 | $(".popover-content").html("
"); 14 | $("#notifications").removeClass("new-notifications"); 15 | }, 16 | success: function (data) { 17 | $(".popover-content").html(data); 18 | } 19 | }); 20 | } 21 | return false; 22 | }); 23 | 24 | function check_notifications() { 25 | $.ajax({ 26 | url: '/notifications/check/', 27 | cache: false, 28 | success: function (data) { 29 | if (data != "0") { 30 | $("#notifications").addClass("new-notifications"); 31 | } 32 | else { 33 | $("#notifications").removeClass("new-notifications"); 34 | } 35 | }, 36 | complete: function () { 37 | window.setTimeout(check_notifications, 30000); 38 | } 39 | }); 40 | }; 41 | check_notifications(); 42 | }); -------------------------------------------------------------------------------- /bootcamp/activities/templates/activities/last_notifications.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load humanize %} 3 | 4 | 16 | -------------------------------------------------------------------------------- /bootcamp/activities/templates/activities/notifications.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% load humanize %} 6 | 7 | {% block title %} {% trans 'Notifications' %} {% endblock %} 8 | 9 | {% block head %} 10 | 11 | {% endblock head %} 12 | 13 | {% block main %} 14 | 17 | 30 | {% endblock main %} 31 | -------------------------------------------------------------------------------- /bootcamp/activities/tests.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.test import TestCase, Client 4 | from django.contrib.auth.models import User 5 | 6 | from .models import Activity, Notification 7 | from bootcamp.feeds.models import Feed 8 | 9 | 10 | class NotificationMethodTests(TestCase): 11 | def setUp(self): 12 | from_user = User.objects.create_user( 13 | username='test_from_user', 14 | email='test@test.com', 15 | password='password' 16 | ) 17 | to_user = User.objects.create_user( 18 | username='test_to_user', 19 | email='test_to@test.com', 20 | password='password' 21 | ) 22 | feed = Feed.objects.create(user=from_user, post='test feed post') 23 | Activity.objects.create(user=to_user, activity_type=Activity.LIKE) 24 | 25 | self.notification = Notification.objects.create( 26 | notification_type=Notification.LIKED, 27 | from_user=from_user, 28 | to_user=to_user, 29 | feed=feed 30 | ) 31 | 32 | def test_notification_str(self): 33 | str(self.notification) 34 | 35 | 36 | class ActivitiesViewsTests(TestCase): 37 | def setUp(self): 38 | self.client = Client(HTTP_X_REQUESTED_WITH='XMLHttpRequest') 39 | User.objects.create_user( 40 | username='test_user', 41 | email='lennon@thebeatles.com', 42 | password='test_password' 43 | ) 44 | self.client.login(username='test_user', password='test_password') 45 | 46 | def test_notifications(self): 47 | response = self.client.get('/notifications/') 48 | self.assertEqual(response.status_code, HTTPStatus.OK) 49 | 50 | def test_ajax_notifications(self): 51 | response = self.client.get('/notifications/last/') 52 | self.assertEqual(response.status_code, HTTPStatus.OK) 53 | 54 | response = self.client.get('/notifications/check/') 55 | self.assertEqual(response.status_code, HTTPStatus.OK) 56 | -------------------------------------------------------------------------------- /bootcamp/activities/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .import views 4 | 5 | urlpatterns = [ 6 | path('', views.notifications, name='notifications'), 7 | path('last/', views.last_notifications, name='last_notifications'), 8 | path('check/', views.check_notifications, name='check_notifications'), 9 | ] 10 | -------------------------------------------------------------------------------- /bootcamp/activities/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.http import HttpResponse 3 | from django.contrib.auth.decorators import login_required 4 | 5 | from .models import Notification 6 | 7 | 8 | @login_required 9 | def notifications(request): 10 | user = request.user 11 | all_notifications = Notification.objects.filter(to_user=user) 12 | all_notifications.update(is_read=True) 13 | 14 | context = {'notifications': all_notifications} 15 | return render(request, 'activities/notifications.html', context) 16 | 17 | 18 | @login_required 19 | def last_notifications(request): 20 | user = request.user 21 | last_unread_notifications = Notification.objects.filter( 22 | to_user=user, is_read=False)[:5] 23 | 24 | for unread_notification in last_unread_notifications: 25 | unread_notification.is_read = True 26 | unread_notification.save() 27 | 28 | context = {'notifications': last_unread_notifications} 29 | return render(request, 'activities/last_notifications.html', context) 30 | 31 | 32 | @login_required 33 | def check_notifications(request): 34 | user = request.user 35 | notifications_count = Notification.objects.filter( 36 | to_user=user, is_read=False).count() 37 | return HttpResponse(notifications_count) 38 | -------------------------------------------------------------------------------- /bootcamp/articles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/articles/__init__.py -------------------------------------------------------------------------------- /bootcamp/articles/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Article, Tag, ArticleComment 4 | 5 | admin.site.register(Article) 6 | admin.site.register(Tag) 7 | admin.site.register(ArticleComment) 8 | -------------------------------------------------------------------------------- /bootcamp/articles/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .models import Article 5 | 6 | 7 | class ArticleForm(forms.ModelForm): 8 | status = forms.CharField(widget=forms.HiddenInput()) 9 | title = forms.CharField( 10 | widget=forms.TextInput(attrs={'class': 'form-control'}), 11 | label=_('Title'), 12 | max_length=255) 13 | content = forms.CharField( 14 | widget=forms.Textarea(attrs={'class': 'form-control'}), 15 | max_length=4000, 16 | label=_('Content'), 17 | help_text=" ") 18 | tags = forms.CharField( 19 | widget=forms.TextInput(attrs={'class': 'form-control'}), 20 | max_length=255, 21 | required=False, 22 | label=_('Tags'), 23 | help_text=_( 24 | 'Use spaces to separate the tags, such as "Java Linux Python"')) 25 | 26 | class Meta: 27 | model = Article 28 | fields = ['title', 'content', 'tags', 'status'] 29 | -------------------------------------------------------------------------------- /bootcamp/articles/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-06 10:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Article', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('title', models.CharField(max_length=255)), 22 | ('slug', models.SlugField(blank=True, max_length=255, null=True)), 23 | ('content', models.TextField(max_length=4000)), 24 | ('status', models.CharField(choices=[('D', 'Draft'), ('P', 'Published')], default='D', max_length=1)), 25 | ('create_date', models.DateTimeField(auto_now_add=True)), 26 | ('update_date', models.DateTimeField(blank=True, null=True)), 27 | ('create_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 28 | ('update_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 29 | ], 30 | options={ 31 | 'verbose_name': 'Article', 32 | 'verbose_name_plural': 'Articles', 33 | 'ordering': ('-create_date',), 34 | }, 35 | ), 36 | migrations.CreateModel( 37 | name='Tag', 38 | fields=[ 39 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 40 | ('tag', models.CharField(max_length=50)), 41 | ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='articles.article')), 42 | ], 43 | options={ 44 | 'verbose_name': 'Tag', 45 | 'verbose_name_plural': 'Tags', 46 | }, 47 | ), 48 | migrations.CreateModel( 49 | name='ArticleComment', 50 | fields=[ 51 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 52 | ('comment', models.CharField(max_length=500)), 53 | ('date', models.DateTimeField(auto_now_add=True)), 54 | ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='articles.article')), 55 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 56 | ], 57 | options={ 58 | 'verbose_name': 'Article Comment', 59 | 'verbose_name_plural': 'Article Comments', 60 | 'ordering': ('date',), 61 | }, 62 | ), 63 | migrations.AddConstraint( 64 | model_name='tag', 65 | constraint=models.UniqueConstraint(fields=('tag', 'article'), name='unique_article_tag'), 66 | ), 67 | ] 68 | -------------------------------------------------------------------------------- /bootcamp/articles/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/articles/migrations/__init__.py -------------------------------------------------------------------------------- /bootcamp/articles/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | from django.template.defaultfilters import slugify 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | 9 | class Article(models.Model): 10 | DRAFT = 'D' 11 | PUBLISHED = 'P' 12 | STATUS = ( 13 | (DRAFT, 'Draft'), 14 | (PUBLISHED, 'Published'), 15 | ) 16 | 17 | title = models.CharField(max_length=255) 18 | slug = models.SlugField(max_length=255, null=True, blank=True) 19 | content = models.TextField(max_length=4000) 20 | status = models.CharField(max_length=1, choices=STATUS, default=DRAFT) 21 | create_user = models.ForeignKey(User, on_delete=models.CASCADE) 22 | create_date = models.DateTimeField(auto_now_add=True) 23 | update_date = models.DateTimeField(blank=True, null=True) 24 | update_user = models.ForeignKey(User, null=True, blank=True, 25 | related_name="+", on_delete=models.CASCADE) 26 | 27 | class Meta: 28 | verbose_name = _("Article") 29 | verbose_name_plural = _("Articles") 30 | ordering = ("-create_date",) 31 | 32 | def __str__(self): 33 | return self.title 34 | 35 | def save(self, *args, **kwargs): 36 | if not self.pk: 37 | super(Article, self).save(*args, **kwargs) 38 | else: 39 | self.update_date = datetime.now() 40 | if not self.slug: 41 | slug_str = "%s %s" % (self.pk, self.title.lower()) 42 | self.slug = slugify(slug_str) 43 | super(Article, self).save(*args, **kwargs) 44 | 45 | def get_content_as_markdown(self): 46 | return self.content 47 | 48 | @staticmethod 49 | def get_published(): 50 | articles = Article.objects.filter(status=Article.PUBLISHED) 51 | return articles 52 | 53 | def create_tags(self, tags): 54 | tags = tags.strip() 55 | tag_list = tags.split(' ') 56 | for tag in tag_list: 57 | if tag: 58 | t, created = Tag.objects.get_or_create(tag=tag.lower(), 59 | article=self) 60 | 61 | def get_tags(self): 62 | return Tag.objects.filter(article=self) 63 | 64 | def get_summary(self): 65 | if len(self.content) > 255: 66 | return f'{self.content[:255]}...' 67 | else: 68 | return self.content 69 | 70 | def get_summary_as_markdown(self): 71 | return self.get_summary() 72 | 73 | def get_comments(self): 74 | return ArticleComment.objects.filter(article=self) 75 | 76 | 77 | class Tag(models.Model): 78 | tag = models.CharField(max_length=50) 79 | article = models.ForeignKey(Article, on_delete=models.CASCADE) 80 | 81 | class Meta: 82 | verbose_name = _('Tag') 83 | verbose_name_plural = _('Tags') 84 | 85 | constraints = [ 86 | models.UniqueConstraint(fields=('tag', 'article'), name='unique_article_tag') 87 | ] 88 | 89 | def __str__(self): 90 | return self.tag 91 | 92 | @staticmethod 93 | def get_popular_tags(): 94 | count = {} 95 | for tag in Tag.objects.all(): 96 | if tag.article.status != Article.PUBLISHED: 97 | continue 98 | if tag.tag in count: 99 | count[tag.tag] += 1 100 | else: 101 | count[tag.tag] = 1 102 | 103 | sorted_count = sorted(list(count.items()), key=lambda t: t[1], 104 | reverse=True) 105 | return sorted_count[:20] 106 | 107 | 108 | class ArticleComment(models.Model): 109 | article = models.ForeignKey(Article, on_delete=models.CASCADE) 110 | comment = models.CharField(max_length=500) 111 | date = models.DateTimeField(auto_now_add=True) 112 | user = models.ForeignKey(User, on_delete=models.CASCADE) 113 | 114 | class Meta: 115 | verbose_name = _("Article Comment") 116 | verbose_name_plural = _("Article Comments") 117 | ordering = ("date",) 118 | 119 | def __str__(self): 120 | return f'{self.user.username} - {self.article.title}' 121 | -------------------------------------------------------------------------------- /bootcamp/articles/static/css/articles.css: -------------------------------------------------------------------------------- 1 | .articles article { 2 | padding-bottom: 1.4em; 3 | border-bottom: 1px solid #eeeeee; 4 | margin-top: 2.4em; 5 | } 6 | 7 | .articles article p { 8 | font-size: 1.2em; 9 | } 10 | 11 | .articles article:last-child { 12 | border-bottom: none; 13 | } 14 | 15 | .info { 16 | margin-bottom: .5em; 17 | color: #a0a0a0; 18 | } 19 | 20 | .info a { 21 | color: #a0a0a0; 22 | } 23 | 24 | .info > span { 25 | margin-right: 1em; 26 | } 27 | 28 | .info .user img { 29 | width: 20px; 30 | border-radius: 3px; 31 | } 32 | 33 | article .tags { 34 | font-size: 1em; 35 | } 36 | 37 | .popular-tags h4 { 38 | margin-top: 2em; 39 | } 40 | 41 | .popular-tags .label { 42 | margin-top: .4em; 43 | font-size: .8em; 44 | line-height: 2; 45 | } 46 | 47 | .popular-tags a:hover, .tags a:hover { 48 | text-decoration: none; 49 | } 50 | 51 | .user-portrait img { 52 | border-radius: 4px; 53 | width: 34px; 54 | } 55 | 56 | .user-portrait { 57 | width: 40px; 58 | float: left; 59 | } 60 | 61 | .comment-input { 62 | float: left; 63 | width: -moz-calc(100% - 40px); 64 | width: -webkit-calc(100% - 40px); 65 | width: calc(100% - 40px); 66 | } 67 | 68 | .post-comment { 69 | margin-bottom: 1em; 70 | } 71 | 72 | .comment-portrait { 73 | width: 40px; 74 | border-radius: 5px; 75 | float: left; 76 | } 77 | 78 | .comment-text { 79 | margin-left: 50px; 80 | } 81 | 82 | .comment-text h5 { 83 | padding-top: .1em; 84 | margin: .2em 0; 85 | } 86 | 87 | .comment-text h5 small { 88 | margin-left: .6em; 89 | } 90 | 91 | .comment-text p { 92 | margin: 0; 93 | font-size: .9em; 94 | } 95 | 96 | .comment { 97 | border-bottom: 1px solid #e3e3e3; 98 | padding: .8em 0; 99 | } 100 | 101 | .comment:last-child { 102 | border-bottom: none; 103 | } -------------------------------------------------------------------------------- /bootcamp/articles/static/js/articles.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $(".publish").click(function () { 3 | $("input[name='status']").val("P"); 4 | $("form").submit(); 5 | }); 6 | 7 | $(".draft").click(function () { 8 | $("input[name='status']").val("D"); 9 | $("form").submit(); 10 | }); 11 | 12 | $(".preview").click(function () { 13 | $.ajax({ 14 | url: '/articles/preview/', 15 | data: $("form").serialize(), 16 | cache: false, 17 | type: 'post', 18 | beforeSend: function () { 19 | $("#preview .modal-body").html("
"); 20 | }, 21 | success: function (data) { 22 | $("#preview .modal-body").html(data); 23 | } 24 | }); 25 | }); 26 | 27 | $("#comment").focus(function () { 28 | $(this).attr("rows", "3"); 29 | $("#comment-helper").fadeIn(); 30 | }); 31 | 32 | $("#comment").blur(function () { 33 | $(this).attr("rows", "1"); 34 | $("#comment-helper").fadeOut(); 35 | }); 36 | 37 | $("#comment").keydown(function (evt) { 38 | var keyCode = evt.which?evt.which:evt.keyCode; 39 | if (evt.ctrlKey && (keyCode == 10 || keyCode == 13)) { 40 | $.ajax({ 41 | url: '/articles/comment/', 42 | data: $("#comment-form").serialize(), 43 | cache: false, 44 | type: 'post', 45 | success: function (data) { 46 | $("#comment-list").html(data); 47 | var comment_count = $("#comment-list .comment").length; 48 | $(".comment-count").text(comment_count); 49 | $("#comment").val(""); 50 | $("#comment").blur(); 51 | } 52 | }); 53 | } 54 | }); 55 | 56 | }); -------------------------------------------------------------------------------- /bootcamp/articles/templates/articles/article.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block title %}{{ article.title }}{% endblock %} 6 | 7 | {% block head %} 8 | 9 | 10 | {% endblock head %} 11 | 12 | {% block main %} 13 | 17 | {% include 'articles/partial_article.html' with article=article %} 18 | {% if not user.is_anonymous %} 19 | {% include 'articles/partial_article_comments.html' with article=article %} 20 | {% endif %} 21 | {% endblock main %} 22 | -------------------------------------------------------------------------------- /bootcamp/articles/templates/articles/articles.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block title %} {% trans 'Articles' %} {% endblock %} 6 | 7 | {% block head %} 8 | 9 | {% endblock head %} 10 | 11 | {% block main %} 12 | 22 |
23 |
24 |
25 | {% for article in articles %} 26 | {% include 'articles/partial_article.html' with article=article %} 27 | {% empty %} 28 |

{% trans 'There is no published article yet' %}. {% trans 'Be the first one to publish' %}!

30 | {% endfor %} 31 |
32 |
33 | 39 |
40 |
41 |
42 | {% include 'paginator.html' with paginator=articles %} 43 |
44 |
45 | {% endblock main %} 46 | -------------------------------------------------------------------------------- /bootcamp/articles/templates/articles/drafts.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block head %} 6 | 7 | {% endblock head %} 8 | 9 | {% block main %} 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for article in drafts %} 24 | 25 | 26 | 27 | 32 | 33 | {% empty %} 34 | 35 | 38 | 39 | {% endfor %} 40 | 41 |
{% trans 'Title' %}{% trans 'Content' %}{% trans 'Tags' %}
{{ article.title }}{{ article.get_summary_as_markdown|safe }} 28 | {% for tag in article.get_tags %} 29 | {{ tag }} 30 | {% endfor %} 31 |
36 | {% trans 'No draft to display' %} 37 |
42 | {% endblock main %} 43 | -------------------------------------------------------------------------------- /bootcamp/articles/templates/articles/edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block head %} 6 | 7 | {% endblock head %} 8 | 9 | {% block main %} 10 | 15 |
16 | {% csrf_token %} 17 | {{ form.status }} 18 | {% for field in form.visible_fields %} 19 |
20 | 21 | {% if field.label == 'Content' %} 22 | {% include 'markdown_editor.html' with textarea='id_content' %} 23 | {% endif %} 24 | {{ field }} 25 | {% if field.help_text %} 26 | {{ field.help_text }} 27 | {% endif %} 28 | {% for error in field.errors %} 29 | 30 | {% endfor %} 31 |
32 | {% endfor %} 33 |
34 | 35 | 36 | {% trans 'Cancel' %} 37 |
38 |
39 | {% endblock main %} 40 | -------------------------------------------------------------------------------- /bootcamp/articles/templates/articles/partial_article.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 |

{{ article.title }}

4 |
5 | 6 | 7 | {{ article.create_date }} 8 | 9 | 10 | 11 | {{ article.create_user.profile.get_screen_name }} 12 | 13 | 14 | 15 | {{ article.get_comments.count }} {% trans 'Comments' %} 16 | 17 |
18 |
19 | {{ article.get_content_as_markdown|safe }} 20 |
21 | {% if article.get_tags %} 22 |
23 | {% for tag in article.get_tags %} 24 | {{ tag.tag }} 25 | {% endfor %} 26 |
27 | {% endif %} 28 |
29 | -------------------------------------------------------------------------------- /bootcamp/articles/templates/articles/partial_article_comment.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 | 3 |
4 | 5 |
6 |
7 | {{ comment.user.profile.get_screen_name }} 8 | {{ comment.date|naturaltime }} 9 |
10 |

{{ comment.comment }}

11 |
12 |
-------------------------------------------------------------------------------- /bootcamp/articles/templates/articles/partial_article_comments.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 | 4 |

{{ article.get_comments.count }} {% trans 'Comments' %}

5 |
6 |
7 | {% csrf_token %} 8 | 9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 |
17 |
18 | {% for comment in article.get_comments %} 19 | {% include 'articles/partial_article_comment.html' with comment=comment %} 20 | {% empty %} 21 |
{% trans 'Be the first one to comment!' %}
22 | {% endfor %} 23 |
-------------------------------------------------------------------------------- /bootcamp/articles/templates/articles/write.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block head %} 6 | 7 | {% endblock head %} 8 | 9 | {% block main %} 10 | 14 |
15 | {% csrf_token %} 16 | {{ form.status }} 17 | {% for field in form.visible_fields %} 18 |
19 | 20 | {% if field.help_text == " " %} 21 | {% include 'markdown_editor.html' with textarea='id_content' %} 22 | {% endif %} 23 | {{ field }} 24 | {% if field.help_text %} 25 | {{ field.help_text }} 26 | {% endif %} 27 | {% for error in field.errors %} 28 | 29 | {% endfor %} 30 |
31 | {% endfor %} 32 |
33 | 34 | 35 | 36 | {% trans 'Cancel' %} 37 |
38 |
39 | 40 | 54 | 55 | {% endblock main %} 56 | -------------------------------------------------------------------------------- /bootcamp/articles/tests.py: -------------------------------------------------------------------------------- 1 | # Create your tests here. 2 | -------------------------------------------------------------------------------- /bootcamp/articles/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .import views 4 | 5 | urlpatterns = [ 6 | path('', views.articles, name='articles'), 7 | path('write/', views.write, name='write'), 8 | path('preview/', views.preview, name='preview'), 9 | path('drafts/', views.drafts, name='drafts'), 10 | path('comment/', views.comment, name='comment'), 11 | path('tag//', views.tag, name='tag'), 12 | path('edit//', views.edit, name='edit_article'), 13 | path(')/', views.article, name='article'), 14 | ] 15 | -------------------------------------------------------------------------------- /bootcamp/articles/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | from django.shortcuts import get_object_or_404 3 | from django.template.loader import render_to_string 4 | from django.utils.translation import gettext_lazy as _ 5 | from django.contrib.auth.decorators import login_required 6 | from django.http import HttpResponseBadRequest, HttpResponse 7 | from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger 8 | 9 | from bootcamp.articles.forms import ArticleForm 10 | from bootcamp.articles.models import Article, Tag, ArticleComment 11 | 12 | 13 | def _articles(request, articles): 14 | paginator = Paginator(articles, 10) 15 | page = request.GET.get('page') 16 | try: 17 | articles = paginator.page(page) 18 | except PageNotAnInteger: 19 | articles = paginator.page(1) 20 | except EmptyPage: 21 | articles = paginator.page(paginator.num_pages) 22 | popular_tags = Tag.get_popular_tags() 23 | return render(request, 'articles/articles.html', 24 | {'articles': articles, 'popular_tags': popular_tags}) 25 | 26 | 27 | def articles(request): 28 | all_articles = Article.get_published() 29 | return _articles(request, all_articles) 30 | 31 | 32 | def article(request, slug): 33 | article = get_object_or_404(Article, slug=slug, status=Article.PUBLISHED) 34 | return render(request, 'articles/article.html', {'article': article}) 35 | 36 | 37 | def tag(request, tag_name): 38 | tags = Tag.objects.filter(tag=tag_name) 39 | articles = [] 40 | for tag in tags: 41 | if tag.article.status == Article.PUBLISHED: 42 | articles.append(tag.article) 43 | return _articles(request, articles) 44 | 45 | 46 | @login_required 47 | def write(request): 48 | if request.method == 'GET': 49 | form = ArticleForm() 50 | return render(request, 'articles/write.html', {'form': form}) 51 | 52 | form = ArticleForm(request.POST) 53 | 54 | if form.is_valid(): 55 | article = Article() 56 | article.create_user = request.user 57 | article.title = form.cleaned_data.get('title') 58 | article.content = form.cleaned_data.get('content') 59 | status = form.cleaned_data.get('status') 60 | 61 | if status in [Article.PUBLISHED, Article.DRAFT]: 62 | article.status = form.cleaned_data.get('status') 63 | 64 | article.save() 65 | 66 | tags = form.cleaned_data.get('tags') 67 | article.create_tags(tags) 68 | return redirect('/articles/') 69 | 70 | return render(request, 'articles/write.html', {'form': form}) 71 | 72 | 73 | @login_required 74 | def drafts(request): 75 | drafts = Article.objects.filter( 76 | create_user=request.user, status=Article.DRAFT) 77 | return render(request, 'articles/drafts.html', {'drafts': drafts}) 78 | 79 | 80 | @login_required 81 | def edit(request, article_id): 82 | tags = '' 83 | if not article_id: 84 | article = get_object_or_404(Article, pk=article_id) 85 | 86 | for tag in article.get_tags(): 87 | tags = f'{tags} {tag.tag}' 88 | tags = tags.strip() 89 | else: 90 | article = Article(create_user=request.user) 91 | 92 | if request.POST: 93 | form = ArticleForm(request.POST, instance=article) 94 | if form.is_valid(): 95 | form.save() 96 | return redirect('/articles/') 97 | else: 98 | form = ArticleForm(instance=article, initial={'tags': tags}) 99 | 100 | return render(request, 'articles/edit.html', {'form': form}) 101 | 102 | 103 | @login_required 104 | def preview(request): 105 | if request.method != 'POST': 106 | return HttpResponseBadRequest 107 | 108 | content = request.POST.get('content') 109 | html = _('Nothing to display :(') 110 | 111 | if len(content.strip()) > 0: 112 | html = content 113 | 114 | return HttpResponse(html) 115 | 116 | 117 | @login_required 118 | def comment(request): 119 | if request.method != 'POST': 120 | return HttpResponseBadRequest() 121 | 122 | article_id = request.POST.get('article') 123 | comment = request.POST.get('comment').strip() 124 | 125 | article = Article.objects.get(pk=article_id) 126 | 127 | if len(comment) > 0: 128 | ArticleComment.objects.create( 129 | user=request.user, 130 | article=article, 131 | comment=comment 132 | ) 133 | 134 | html = '' 135 | for comment in article.get_comments(): 136 | template = render_to_string('articles/partial_article_comment.html', 137 | {'comment': comment}) 138 | html = f'{html}{template}' 139 | 140 | return HttpResponse(html) 141 | -------------------------------------------------------------------------------- /bootcamp/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for mysite project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bootcamp.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /bootcamp/authentication/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/authentication/__init__.py -------------------------------------------------------------------------------- /bootcamp/authentication/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Profile 4 | 5 | admin.site.register(Profile) 6 | -------------------------------------------------------------------------------- /bootcamp/authentication/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | from django.contrib.auth.models import User 4 | from django.core.exceptions import ValidationError 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | def signup_domain_validator(value): 9 | if '*' in settings.ALLOWED_SIGNUP_DOMAINS: 10 | return 11 | 12 | domain = value[value.index("@"):] 13 | 14 | if domain not in settings.ALLOWED_SIGNUP_DOMAINS: 15 | allowed_domain = ','.join(settings.ALLOWED_SIGNUP_DOMAINS) 16 | msg = _('Invalid domain. ' 17 | 'Allowed domains on this network: {0}').format(allowed_domain) 18 | raise ValidationError(msg) 19 | 20 | 21 | def forbidden_username_validator(value): 22 | forbidden_usernames = { 23 | 'admin', 'settings', 'news', 'about', 'help', 'signin', 'signup', 24 | 'signout', 'terms', 'privacy', 'cookie', 'new', 'login', 'logout', 25 | 'administrator', 'join', 'account', 'username', 'root', 'blog', 26 | 'user', 'users', 'billing', 'subscribe', 'reviews', 'review', 'blog', 27 | 'blogs', 'edit', 'mail', 'email', 'home', 'job', 'jobs', 'contribute', 28 | 'newsletter', 'shop', 'profile', 'register', 'auth', 'authentication', 29 | 'campaign', 'config', 'delete', 'remove', 'forum', 'forums', 30 | 'download', 'downloads', 'contact', 'blogs', 'feed', 'feeds', 'faq', 31 | 'intranet', 'log', 'registration', 'search', 'explore', 'rss', 32 | 'support', 'status', 'static', 'media', 'setting', 'css', 'js', 33 | 'follow', 'activity', 'questions', 'articles', 'network', } 34 | if value.lower() in forbidden_usernames: 35 | raise ValidationError(_('This is a reserved word.')) 36 | 37 | 38 | def invalid_username_validator(value): 39 | if '@' in value or '+' in value or '-' in value: 40 | raise ValidationError(_('Enter a valid username.')) 41 | 42 | 43 | def unique_email_validator(value): 44 | if User.objects.filter(email__iexact=value).exists(): 45 | raise ValidationError(_('User with this Email already exists.')) 46 | 47 | 48 | def unique_username_ignore_case_validator(value): 49 | if User.objects.filter(username__iexact=value).exists(): 50 | raise ValidationError(_('User with this Username already exists.')) 51 | 52 | 53 | class SignUpForm(forms.ModelForm): 54 | username = forms.CharField( 55 | widget=forms.TextInput(attrs={'class': 'form-control'}), 56 | max_length=30, 57 | required=True, 58 | label=_('Username'), 59 | help_text=_('Usernames may contain alphanumeric, _ and . characters')) 60 | password = forms.CharField( 61 | widget=forms.PasswordInput(attrs={'class': 'form-control'}), 62 | label=_('Password')) 63 | confirm_password = forms.CharField( 64 | widget=forms.PasswordInput(attrs={'class': 'form-control'}), 65 | label=_('Confirm your password'), 66 | required=True) 67 | email = forms.CharField( 68 | widget=forms.EmailInput(attrs={'class': 'form-control'}), 69 | required=True, 70 | max_length=75, 71 | label=_('Email')) 72 | 73 | class Meta: 74 | model = User 75 | exclude = ['last_login', 'date_joined'] 76 | fields = ['username', 'email', 'password', 'confirm_password', ] 77 | 78 | def __init__(self, *args, **kwargs): 79 | super(SignUpForm, self).__init__(*args, **kwargs) 80 | self.fields['username'].validators += [ 81 | forbidden_username_validator, invalid_username_validator, 82 | unique_username_ignore_case_validator 83 | ] 84 | self.fields['email'].validators += [ 85 | unique_email_validator, signup_domain_validator] 86 | 87 | def clean(self): 88 | super(SignUpForm, self).clean() 89 | password = self.cleaned_data.get('password') 90 | confirm_password = self.cleaned_data.get('confirm_password') 91 | if password and password != confirm_password: 92 | self._errors['password'] = self.error_class( 93 | [_('Passwords don\'t match')]) 94 | return self.cleaned_data 95 | -------------------------------------------------------------------------------- /bootcamp/authentication/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-06 10:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Profile', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('url', models.CharField(blank=True, max_length=50, null=True)), 22 | ('location', models.CharField(blank=True, max_length=50, null=True)), 23 | ('job_title', models.CharField(blank=True, max_length=50, null=True)), 24 | ('picture_url', models.CharField(blank=True, max_length=120, null=True)), 25 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /bootcamp/authentication/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/authentication/migrations/__init__.py -------------------------------------------------------------------------------- /bootcamp/authentication/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | from django.db.models.signals import post_save 4 | 5 | from bootcamp.activities.models import Notification 6 | 7 | 8 | class Profile(models.Model): 9 | user = models.OneToOneField(User, on_delete=models.CASCADE) 10 | url = models.CharField(max_length=50, null=True, blank=True) 11 | location = models.CharField(max_length=50, null=True, blank=True) 12 | job_title = models.CharField(max_length=50, null=True, blank=True) 13 | picture_url = models.CharField(max_length=120, null=True, blank=True) 14 | 15 | def get_url(self): 16 | url = self.url 17 | if not self.url.startswith("http://") \ 18 | and not self.url.startswith("https://") \ 19 | and len(self.url) > 0: 20 | url = "http://" + str(self.url) 21 | return url 22 | 23 | def get_picture(self): 24 | if not self.picture_url: 25 | no_picture = '/static/img/user.png' 26 | return no_picture 27 | 28 | return self.picture_url 29 | 30 | def get_screen_name(self): 31 | if self.user.get_full_name(): 32 | return self.user.get_full_name() 33 | 34 | return self.user.username 35 | 36 | def notify_liked(self, feed): 37 | if self.user != feed.user: 38 | Notification.objects.create( 39 | notification_type=Notification.LIKED, 40 | from_user=self.user, 41 | to_user=feed.user, 42 | feed=feed 43 | ) 44 | 45 | def unotify_liked(self, feed): 46 | if self.user != feed.user: 47 | Notification.objects.filter( 48 | notification_type=Notification.LIKED, 49 | from_user=self.user, 50 | to_user=feed.user, 51 | feed=feed 52 | ).delete() 53 | 54 | def notify_commented(self, feed): 55 | if self.user != feed.user: 56 | Notification( 57 | notification_type=Notification.COMMENTED, 58 | from_user=self.user, 59 | to_user=feed.user, 60 | feed=feed 61 | ).save() 62 | 63 | def notify_also_commented(self, feed): 64 | comments = feed.get_comments() 65 | users = set() 66 | 67 | for comment in comments: 68 | if comment.user != self.user and comment.user != feed.user: 69 | users.add(comment.user.pk) 70 | 71 | for user in users: 72 | Notification( 73 | notification_type=Notification.ALSO_COMMENTED, 74 | from_user=self.user, 75 | to_user=User(id=user), 76 | feed=feed 77 | ).save() 78 | 79 | def notify_favorited(self, question): 80 | if self.user != question.user: 81 | Notification( 82 | notification_type=Notification.FAVORITED, 83 | from_user=self.user, 84 | to_user=question.user, 85 | question=question 86 | ).save() 87 | 88 | def unotify_favorited(self, question): 89 | if self.user != question.user: 90 | Notification.objects.filter( 91 | notification_type=Notification.FAVORITED, 92 | from_user=self.user, 93 | to_user=question.user, 94 | question=question 95 | ).delete() 96 | 97 | def notify_answered(self, question): 98 | if self.user != question.user: 99 | Notification.objects.create( 100 | notification_type=Notification.ANSWERED, 101 | from_user=self.user, 102 | to_user=question.user, 103 | question=question 104 | ) 105 | 106 | def notify_accepted(self, answer): 107 | if self.user != answer.user: 108 | Notification.objects.create( 109 | notification_type=Notification.ACCEPTED_ANSWER, 110 | from_user=self.user, 111 | to_user=answer.user, 112 | answer=answer 113 | ) 114 | 115 | def unotify_accepted(self, answer): 116 | if self.user != answer.user: 117 | Notification.objects.filter( 118 | notification_type=Notification.ACCEPTED_ANSWER, 119 | from_user=self.user, 120 | to_user=answer.user, 121 | answer=answer 122 | ).delete() 123 | 124 | 125 | def create_user_profile(sender, instance, created, **kwargs): 126 | if created: 127 | Profile.objects.create(user=instance) 128 | 129 | 130 | def save_user_profile(sender, instance, **kwargs): 131 | instance.profile.save() 132 | 133 | 134 | post_save.connect(create_user_profile, sender=User) 135 | post_save.connect(save_user_profile, sender=User) 136 | -------------------------------------------------------------------------------- /bootcamp/authentication/schema.py: -------------------------------------------------------------------------------- 1 | from django.core import signing 2 | 3 | import graphene 4 | from graphql.error import GraphQLError 5 | from django.contrib.auth.models import User 6 | from graphene_django import DjangoObjectType, DjangoConnectionField 7 | 8 | from ..authentication.models import Profile 9 | 10 | 11 | class UserObject(DjangoObjectType): 12 | class Meta: 13 | model = User 14 | interfaces = (graphene.Node,) 15 | 16 | 17 | class ProfileObject(DjangoObjectType): 18 | class Meta: 19 | model = Profile 20 | 21 | 22 | class UserQuery(graphene.ObjectType): 23 | profile = graphene.Field(ProfileObject) 24 | 25 | user = graphene.Node.Field(UserObject) 26 | users = DjangoConnectionField(UserObject) 27 | 28 | def resolve_users(self, info, **kwargs): 29 | return User.objects.all().select_related('profile') 30 | 31 | 32 | class AuthInput(graphene.InputObjectType): 33 | username = graphene.String(required=True) 34 | password = graphene.String(required=True) 35 | 36 | 37 | class CreateToken(graphene.Mutation): 38 | class Arguments: 39 | auth = AuthInput(required=True) 40 | 41 | token = graphene.String(required=True) 42 | 43 | @staticmethod 44 | def mutate(root, info, auth): 45 | user = User.objects.filter(username=auth.username).first() 46 | if not user or not user.check_password(auth.password): 47 | raise GraphQLError('username or password is invalid.') 48 | 49 | return CreateToken(token=signing.dumps(user.id)) 50 | -------------------------------------------------------------------------------- /bootcamp/authentication/static/css/signup.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #006389; 3 | } 4 | 5 | .logo { 6 | margin: 0; 7 | font-size: 3em; 8 | font-weight: 200; 9 | font-family: "Audiowide", cursive; 10 | text-align: center; 11 | text-shadow: 1px 1px 0 #333333; 12 | line-height: 70px; 13 | } 14 | 15 | .logo a { 16 | color: #92c35e; 17 | } 18 | 19 | .logo a:hover, .logo a:focus { 20 | color: #92c35e; 21 | text-decoration: none; 22 | } 23 | 24 | .signup { 25 | background-color: #f6f6f6; 26 | width: 70%; 27 | border-radius: 7px; 28 | margin: 10px auto 0; 29 | } 30 | 31 | .signup { 32 | padding: 30px 20px; 33 | } 34 | 35 | .signup form { 36 | margin-top: 20px; 37 | } 38 | 39 | .signup h2 { 40 | margin: 0; 41 | } -------------------------------------------------------------------------------- /bootcamp/authentication/templates/auth/signup.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block head %} 6 | 7 | {% endblock head %} 8 | 9 | {% block body %} 10 |
11 |

Bootcamp

12 | 31 |
32 | {% endblock body %} 33 | -------------------------------------------------------------------------------- /bootcamp/authentication/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.contrib.auth.models import User 3 | 4 | 5 | class ProfileMethodTests(TestCase): 6 | def setUp(self): 7 | user = User.objects.create_user( 8 | username='lichun', 9 | email='i@lichun.me', 10 | password='lichun_password', 11 | ) 12 | user.profile.url = 'https://lichun.me/' 13 | user.save() 14 | 15 | def test_get_profile(self): 16 | user = User.objects.get(username='lichun') 17 | 18 | url = user.profile.get_url() 19 | self.assertEqual(url, 'https://lichun.me/') 20 | 21 | picture_url = user.profile.get_picture() 22 | self.assertEqual(picture_url, '/static/img/user.png') 23 | 24 | screen_name = user.profile.get_screen_name() 25 | self.assertEqual(screen_name, 'lichun') 26 | -------------------------------------------------------------------------------- /bootcamp/authentication/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import authenticate, login 2 | from django.contrib.auth.models import User 3 | from django.shortcuts import render, redirect 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from bootcamp.feeds.models import Feed 7 | from .forms import SignUpForm 8 | 9 | 10 | def signup(request): 11 | if request.method != 'POST': 12 | return render(request, 'auth/signup.html', {'form': SignUpForm()}) 13 | 14 | form = SignUpForm(request.POST) 15 | 16 | if not form.is_valid(): 17 | return render(request, 'auth/signup.html', {'form': form}) 18 | 19 | email = form.cleaned_data.get('email') 20 | username = form.cleaned_data.get('username') 21 | password = form.cleaned_data.get('password') 22 | 23 | User.objects.create_user(username=username, password=password, 24 | email=email) 25 | user = authenticate(username=username, password=password) 26 | login(request, user) 27 | 28 | welcome_post = _('{0} has joined the network.').format(user.username) 29 | Feed.objects.create(user=user, post=welcome_post) 30 | 31 | return redirect('/') 32 | -------------------------------------------------------------------------------- /bootcamp/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/core/__init__.py -------------------------------------------------------------------------------- /bootcamp/core/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth.models import User 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class ProfileForm(forms.ModelForm): 7 | first_name = forms.CharField( 8 | widget=forms.TextInput(attrs={'class': 'form-control'}), 9 | max_length=30, label=_('First Name'), required=False) 10 | last_name = forms.CharField( 11 | widget=forms.TextInput(attrs={'class': 'form-control'}), 12 | max_length=30, label=_('Last Name'), required=False) 13 | job_title = forms.CharField( 14 | widget=forms.TextInput(attrs={'class': 'form-control'}), 15 | max_length=50, label=_('Job Title'), required=False) 16 | email = forms.CharField( 17 | widget=forms.TextInput(attrs={'class': 'form-control'}), 18 | max_length=75, label=_('Email'), required=False) 19 | url = forms.CharField( 20 | widget=forms.TextInput(attrs={'class': 'form-control'}), 21 | max_length=50, label=_('Url'), required=False) 22 | location = forms.CharField( 23 | widget=forms.TextInput(attrs={'class': 'form-control'}), 24 | max_length=50, label=_('Location'), required=False) 25 | 26 | class Meta: 27 | model = User 28 | fields = ['first_name', 'last_name', 'job_title', 'email', 'url', 29 | 'location', ] 30 | 31 | 32 | class ChangePasswordForm(forms.ModelForm): 33 | id = forms.CharField(widget=forms.HiddenInput()) 34 | old_password = forms.CharField( 35 | widget=forms.PasswordInput(attrs={'class': 'form-control'}), 36 | label=_("Old password"), required=True) 37 | new_password = forms.CharField( 38 | widget=forms.PasswordInput(attrs={'class': 'form-control'}), 39 | label=_("New password"), required=True) 40 | confirm_password = forms.CharField( 41 | widget=forms.PasswordInput(attrs={'class': 'form-control'}), 42 | label=_("Confirm new password"), required=True) 43 | 44 | class Meta: 45 | model = User 46 | fields = ['id', 'old_password', 'new_password', 'confirm_password'] 47 | 48 | def clean(self): 49 | super(ChangePasswordForm, self).clean() 50 | 51 | old_password = self.cleaned_data.get('old_password') 52 | new_password = self.cleaned_data.get('new_password') 53 | confirm_password = self.cleaned_data.get('confirm_password') 54 | 55 | user_id = self.cleaned_data.get('id') 56 | user = User.objects.get(pk=user_id) 57 | 58 | if not user.check_password(old_password): 59 | self._errors['old_password'] = self.error_class( 60 | ['Old password don\'t match']) 61 | 62 | if new_password and new_password != confirm_password: 63 | self._errors['new_password'] = self.error_class( 64 | ['Passwords don\'t match']) 65 | 66 | return self.cleaned_data 67 | 68 | 69 | class SavePictureForm(forms.Form): 70 | x = forms.IntegerField(min_value=0) 71 | y = forms.IntegerField(min_value=0) 72 | width = forms.IntegerField(min_value=0) 73 | height = forms.IntegerField(min_value=0) 74 | -------------------------------------------------------------------------------- /bootcamp/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/core/migrations/__init__.py -------------------------------------------------------------------------------- /bootcamp/core/static/css/cover.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #006389; 3 | } 4 | 5 | .cover { 6 | width: 400px; 7 | height: 400px; 8 | position: fixed; 9 | margin-left: -200px; 10 | margin-top: -200px; 11 | top: 50%; 12 | left: 50%; 13 | } 14 | 15 | .logo { 16 | margin: 0; 17 | font-size: 3em; 18 | font-weight: 200; 19 | font-family: "Audiowide", cursive; 20 | color: #92c35e; 21 | text-align: center; 22 | text-shadow: 1px 1px 0 #333333; 23 | line-height: 70px; 24 | } 25 | 26 | .login { 27 | background-color: #f6f6f6; 28 | width: 100%; 29 | height: 330px; 30 | border-radius: 7px; 31 | } 32 | 33 | .login { 34 | padding: 10px 20px; 35 | } 36 | 37 | .login form { 38 | margin-top: 20px; 39 | } -------------------------------------------------------------------------------- /bootcamp/core/static/css/network.css: -------------------------------------------------------------------------------- 1 | .users { 2 | margin-top: 2em; 3 | } 4 | 5 | .users .panel-heading img { 6 | margin-right: .6em; 7 | } -------------------------------------------------------------------------------- /bootcamp/core/static/css/profile.css: -------------------------------------------------------------------------------- 1 | .profile { 2 | margin-top: 1em; 3 | } 4 | 5 | .user-profile .picture { 6 | width: 200px; 7 | border-radius: 5px; 8 | } 9 | 10 | .user-profile ul { 11 | padding: 0; 12 | margin-top: .6em; 13 | } 14 | 15 | .user-profile ul li { 16 | list-style: none; 17 | } -------------------------------------------------------------------------------- /bootcamp/core/static/js/picture.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | 3 | var jcrop_api, 4 | xsize = 200, 5 | ysize = 200; 6 | 7 | $("#crop-picture").Jcrop({ 8 | aspectRatio: xsize / ysize, 9 | onSelect: updateCoords, 10 | setSelect: [0, 0, 200, 200] 11 | }); 12 | 13 | function updateCoords(c) { 14 | $("#x").val(c.x); 15 | $("#y").val(c.y); 16 | $("#width").val(c.w); 17 | $("#height").val(c.h); 18 | }; 19 | 20 | $("#btn-upload-picture").click(function () { 21 | $("#picture-upload-form input[name='picture']").click(); 22 | }); 23 | 24 | $("#picture-upload-form input[name='picture']").change(function () { 25 | $("#picture-upload-form").submit(); 26 | }); 27 | 28 | }); 29 | -------------------------------------------------------------------------------- /bootcamp/core/templates/core/cover.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block head %} 6 | 7 | {% endblock head %} 8 | 9 | {% block body %} 10 |
11 |

Bootcamp

12 | {% if form.non_field_errors %} 13 | {% for error in form.non_field_errors %} 14 |
15 | 16 | {{ error }} 17 |
18 | {% endfor %} 19 | {% endif %} 20 | 44 |
45 | {% endblock body %} 46 | -------------------------------------------------------------------------------- /bootcamp/core/templates/core/network.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block title %}{% trans 'Network' %}{% endblock %} 6 | 7 | {% block head %} 8 | 9 | {% endblock head %} 10 | 11 | {% block main %} 12 | 15 |
16 |
17 | {% for user in users %} 18 |
19 |
20 | 24 |
25 | {% if user.profile.job_title %} 26 |

{% trans 'Job Title' %}: {{ user.profile.job_title }}

27 | {% endif %} 28 |

{% trans 'Username' %}: {{ user.username }}

29 |

{% trans 'Email' %}: {{ user.email }}

30 | {% if user.profile.location %} 31 |

{% trans 'Location' %}: {{ user.profile.location }}

32 | {% endif %} 33 | {% if user.profile.url %} 34 |

{% trans 'Url' %}: {{ user.profile.get_url }}

35 | {% endif %} 36 |
37 |
38 |
39 | {% if forloop.counter|divisibleby:3 %}
{% endif %} 40 | {% endfor %} 41 |
42 |
43 | {% endblock main %} 44 | -------------------------------------------------------------------------------- /bootcamp/core/templates/core/partial_settings_menu.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 7 | -------------------------------------------------------------------------------- /bootcamp/core/templates/core/password.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans 'Account Settings' %}{% endblock %} 5 | 6 | {% block main %} 7 | 10 |
11 |
12 | {% include 'core/partial_settings_menu.html' with active='password' %} 13 |
14 |
15 | {% if messages %} 16 | {% for message in messages %} 17 |
18 | 19 | {{ message }} 20 |
21 | {% endfor %} 22 | {% endif %} 23 |

{% trans 'Change Password' %}

24 |
25 | {% csrf_token %} 26 | {{ form.id }} 27 | {% for field in form.visible_fields %} 28 |
29 | 30 |
31 | {{ field }} 32 | {% if field.help_text %} 33 | {{ field.help_text }} 34 | {% endif %} 35 | {% for error in field.errors %} 36 | 37 | {% endfor %} 38 |
39 |
40 | {% endfor %} 41 |
42 |
43 | 44 |
45 |
46 |
47 |
48 |
49 | {% endblock main %} 50 | -------------------------------------------------------------------------------- /bootcamp/core/templates/core/picture.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block title %}{% trans 'Account Settings' %}{% endblock %} 6 | 7 | {% block head %} 8 | 9 | 10 | 11 | {% endblock head %} 12 | 13 | {% block main %} 14 | 17 |
18 |
19 | {% include 'core/partial_settings_menu.html' with active='picture' %} 20 |
21 |
22 | {% if messages %} 23 | {% for message in messages %} 24 |
25 | 26 | {{ message }} 27 |
28 | {% endfor %} 29 | {% endif %} 30 |

{% trans 'Change Picture' %}

31 | 32 |
33 | {% csrf_token %} 34 | 35 | 36 |
37 | 38 | {% if uploaded_picture %} 39 |
40 | {% csrf_token %} 41 | 71 |
72 | {% endif %} 73 |
74 |
75 | {% endblock main %} 76 | -------------------------------------------------------------------------------- /bootcamp/core/templates/core/profile.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block title %}{{ page_user.profile.get_screen_name }}{% endblock %} 6 | 7 | {% block head %} 8 | 9 | 10 | 11 | {% endblock head %} 12 | 13 | {% block main %} 14 | 18 |
19 |
20 | 39 |
40 |

{{ page_user.profile.get_screen_name }} {% trans 'Last Feeds by' %}

41 |
42 | new posts 43 |
44 |
    45 | {% for feed in feeds %} 46 | {% include 'feeds/partial_feed.html' with feed=feed %} 47 | {% endfor %} 48 |
49 |
50 | 51 |
52 |
53 | 54 | 55 | 56 |
57 |
58 |
59 |
60 | {% endblock main %} 61 | -------------------------------------------------------------------------------- /bootcamp/core/templates/core/settings.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% get_available_languages as LANGUAGES %} 4 | {% get_current_language as CURRENT_LANGUAGE %} 5 | 6 | {% block title %}{% trans 'Account Settings' %}{% endblock %} 7 | 8 | {% block main %} 9 | 12 |
13 |
14 | {% include 'core/partial_settings_menu.html' with active='profile' %} 15 |
16 |
17 | {% if messages %} 18 | {% for message in messages %} 19 |
20 | 21 | {{ message }} 22 |
23 | {% endfor %} 24 | {% endif %} 25 |

{% trans 'Edit Profile' %}

26 |
27 | {% csrf_token %} 28 | {% for field in form.visible_fields %} 29 |
30 | 31 |
32 | {{ field }} 33 | {% if field.help_text %} 34 | {{ field.help_text }} 35 | {% endif %} 36 | {% for error in field.errors %} 37 | 38 | {% endfor %} 39 |
40 |
41 | {% endfor %} 42 | {% comment %} 43 |
44 | 45 |
46 | 51 |
52 |
53 | {% endcomment %} 54 |
55 |
56 | 57 |
58 |
59 |
60 |
61 |
62 | {% endblock main %} 63 | -------------------------------------------------------------------------------- /bootcamp/core/tests.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.test import TestCase, Client 4 | from django.contrib.auth.models import User 5 | 6 | 7 | class CoreViewsTest(TestCase): 8 | def setUp(self): 9 | self.client = Client() 10 | User.objects.create_user( 11 | username='test_user', 12 | email='lennon@thebeatles.com', 13 | password='test_password' 14 | ) 15 | self.client.login(username='test_user', password='test_password') 16 | 17 | def test_home(self): 18 | response = self.client.get('/') 19 | self.assertEqual(response.status_code, HTTPStatus.OK) 20 | 21 | def test_network(self): 22 | response = self.client.get('/network/') 23 | self.assertEqual(response.status_code, HTTPStatus.OK) 24 | 25 | def test_profile(self): 26 | response = self.client.get('/test_user/') 27 | self.assertEqual(response.status_code, HTTPStatus.OK) 28 | 29 | def test_settings(self): 30 | response = self.client.get('/settings/') 31 | self.assertEqual(response.status_code, HTTPStatus.OK) 32 | 33 | def test_picture(self): 34 | response = self.client.get('/settings/picture/') 35 | self.assertEqual(response.status_code, HTTPStatus.OK) 36 | 37 | def test_password(self): 38 | response = self.client.get('/settings/password/') 39 | self.assertEqual(response.status_code, HTTPStatus.OK) 40 | -------------------------------------------------------------------------------- /bootcamp/core/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('', views.settings, name='settings'), 7 | path('picture/', views.picture, name='picture'), 8 | path('password/', views.password, name='password'), 9 | path('upload_picture/', views.upload_picture, name='upload_picture'), 10 | path('save_uploaded_picture/', views.save_uploaded_picture, name='save_uploaded_picture'), 11 | ] 12 | -------------------------------------------------------------------------------- /bootcamp/core/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.core.paginator import Paginator 3 | from django.contrib.auth.models import User 4 | from django.views.decorators.cache import cache_page 5 | from django.utils.translation import gettext_lazy as _ 6 | from django.contrib.auth.decorators import login_required 7 | from django.shortcuts import render, redirect, get_object_or_404 8 | 9 | from bootcamp.feeds.models import Feed 10 | from bootcamp.feeds.views import feeds, FEEDS_NUM_PAGES 11 | 12 | from .forms import ProfileForm, ChangePasswordForm, SavePictureForm 13 | 14 | 15 | def home(request): 16 | return feeds(request) 17 | 18 | 19 | @cache_page(60 * 15) 20 | def network(request): 21 | users = User.objects.filter(is_active=True).order_by('username') 22 | return render(request, 'core/network.html', {'users': users}) 23 | 24 | 25 | def profile(request, username): 26 | page_user = get_object_or_404(User, username=username) 27 | all_feeds = Feed.get_feeds().filter(user=page_user) 28 | paginator = Paginator(all_feeds, FEEDS_NUM_PAGES) 29 | feeds = paginator.page(1) 30 | 31 | from_feed = -1 32 | 33 | if feeds: 34 | from_feed = feeds[0].id 35 | 36 | context = { 37 | 'page_user': page_user, 'feeds': feeds, 38 | 'from_feed': from_feed, 'page': 1 39 | } 40 | return render(request, 'core/profile.html', context) 41 | 42 | 43 | @login_required 44 | def settings(request): 45 | user = request.user 46 | if request.method == 'POST': 47 | form = ProfileForm(request.POST) 48 | if form.is_valid(): 49 | user.first_name = form.cleaned_data.get('first_name') 50 | user.last_name = form.cleaned_data.get('last_name') 51 | user.profile.job_title = form.cleaned_data.get('job_title') 52 | user.email = form.cleaned_data.get('email') 53 | user.profile.url = form.cleaned_data.get('url') 54 | user.profile.location = form.cleaned_data.get('location') 55 | user.save() 56 | 57 | message = _('Your profile were successfully edited.') 58 | messages.add_message(request, messages.SUCCESS, message) 59 | else: 60 | initial = { 61 | 'job_title': user.profile.job_title, 62 | 'url': user.profile.url, 63 | 'location': user.profile.location 64 | } 65 | form = ProfileForm(instance=user, initial=initial) 66 | 67 | return render(request, 'core/settings.html', {'form': form}) 68 | 69 | 70 | @login_required 71 | def picture(request): 72 | uploaded_picture = False 73 | picture_url = None 74 | 75 | if request.GET.get('upload_picture') == 'uploaded': 76 | uploaded_picture = True 77 | picture_url = "" 78 | 79 | context = { 80 | 'uploaded_picture': uploaded_picture, 81 | 'picture_url': picture_url, 82 | } 83 | return render(request, 'core/picture.html', context) 84 | 85 | 86 | @login_required 87 | def password(request): 88 | user = request.user 89 | 90 | if request.method == 'POST': 91 | form = ChangePasswordForm(request.POST) 92 | 93 | if form.is_valid(): 94 | new_password = form.cleaned_data.get('new_password') 95 | user.set_password(new_password) 96 | user.save() 97 | 98 | message = _('Your password were successfully changed.') 99 | messages.add_message(request, messages.SUCCESS, message) 100 | else: 101 | form = ChangePasswordForm(instance=user) 102 | 103 | return render(request, 'core/password.html', {'form': form}) 104 | 105 | 106 | @login_required 107 | def upload_picture(request): 108 | # username = request.user.username 109 | # request.FILES['picture'], public_id=username, width=400, crop='limit' 110 | return redirect('/settings/picture/?upload_picture=uploaded') 111 | 112 | 113 | @login_required 114 | def save_uploaded_picture(request): 115 | form = SavePictureForm(request.POST) 116 | user = request.user 117 | 118 | if form.is_valid(): 119 | form.cleaned_data.update(crop='crop') 120 | user.profile.picture_url = "" 121 | user.save() 122 | 123 | return redirect('/settings/picture/') 124 | -------------------------------------------------------------------------------- /bootcamp/feeds/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/feeds/__init__.py -------------------------------------------------------------------------------- /bootcamp/feeds/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Feed 4 | 5 | admin.site.register(Feed) 6 | -------------------------------------------------------------------------------- /bootcamp/feeds/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-06 10:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Feed', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('date', models.DateTimeField(auto_now_add=True)), 22 | ('post', models.TextField(max_length=255)), 23 | ('likes', models.IntegerField(default=0)), 24 | ('comments', models.IntegerField(default=0)), 25 | ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='feeds.feed')), 26 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 27 | ], 28 | options={ 29 | 'verbose_name': 'Feed', 30 | 'verbose_name_plural': 'Feeds', 31 | 'ordering': ('-date',), 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /bootcamp/feeds/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/feeds/migrations/__init__.py -------------------------------------------------------------------------------- /bootcamp/feeds/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from bootcamp.activities.models import Activity 6 | 7 | 8 | class Feed(models.Model): 9 | user = models.ForeignKey(User, on_delete=models.CASCADE) 10 | date = models.DateTimeField(auto_now_add=True) 11 | post = models.TextField(max_length=255) 12 | parent = models.ForeignKey('Feed', null=True, blank=True, on_delete=models.CASCADE) 13 | likes = models.IntegerField(default=0) 14 | comments = models.IntegerField(default=0) 15 | 16 | class Meta: 17 | verbose_name = _('Feed') 18 | verbose_name_plural = _('Feeds') 19 | ordering = ('-date',) 20 | 21 | def __str__(self): 22 | return self.post 23 | 24 | @staticmethod 25 | def get_feeds(from_feed=None): 26 | if from_feed is not None: 27 | feeds = Feed.objects.filter(parent=None, id__lte=from_feed) 28 | else: 29 | feeds = Feed.objects.filter(parent=None) 30 | return feeds.prefetch_related('user__profile') 31 | 32 | @staticmethod 33 | def get_feeds_after(feed): 34 | feeds = Feed.objects.filter(parent=None, id__gt=feed) 35 | return feeds 36 | 37 | def get_comments(self): 38 | return Feed.objects.filter(parent=self).order_by('date') 39 | 40 | def calculate_likes(self): 41 | likes = Activity.objects.filter(activity_type=Activity.LIKE, 42 | feed=self.pk).count() 43 | self.likes = likes 44 | self.save() 45 | return self.likes 46 | 47 | def get_likes(self): 48 | likes = Activity.objects.filter(activity_type=Activity.LIKE, 49 | feed=self.pk) 50 | return likes 51 | 52 | def get_likers(self): 53 | likes = self.get_likes() 54 | likers = [] 55 | for like in likes: 56 | likers.append(like.user) 57 | return likers 58 | 59 | def calculate_comments(self): 60 | self.comments = Feed.objects.filter(parent=self).count() 61 | self.save() 62 | return self.comments 63 | 64 | def comment(self, user, post): 65 | feed_comment = Feed(user=user, post=post, parent=self) 66 | feed_comment.save() 67 | self.comments = Feed.objects.filter(parent=self).count() 68 | self.save() 69 | return feed_comment 70 | 71 | @classmethod 72 | def like(cls, feed_id, user): 73 | feed = Feed.objects.get(pk=feed_id) 74 | like = Activity.objects.filter( 75 | activity_type=Activity.LIKE, feed=feed_id, user=user) 76 | 77 | if like: 78 | user.profile.unotify_liked(feed) 79 | like.delete() 80 | else: 81 | Activity.objects.create( 82 | feed=feed_id, 83 | user=user, 84 | activity_type=Activity.LIKE 85 | ) 86 | user.profile.notify_liked(feed) 87 | 88 | feed.calculate_likes() 89 | return feed 90 | -------------------------------------------------------------------------------- /bootcamp/feeds/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from graphql_relay import from_global_id 4 | from graphene_django import DjangoObjectType 5 | from graphene_django.fields import DjangoConnectionField 6 | 7 | from .models import Feed 8 | 9 | 10 | class FeedObject(DjangoObjectType): 11 | class Meta: 12 | model = Feed 13 | interfaces = [graphene.Node] 14 | 15 | 16 | class FeedQuery(graphene.ObjectType): 17 | feed = graphene.Node.Field(FeedObject) 18 | feeds = DjangoConnectionField(FeedObject, first=graphene.Int(default_value=5)) 19 | 20 | def resolve_feeds(self, info, **kwargs): 21 | return Feed.objects.select_related( 22 | 'user', 'user__profile' 23 | ).prefetch_related('feed_set').filter(parent=None) 24 | 25 | 26 | class FeedInput(graphene.InputObjectType): 27 | post = graphene.String(required=True) 28 | 29 | 30 | class CreateFeed(graphene.Mutation): 31 | class Arguments: 32 | feed = FeedInput(required=True) 33 | 34 | feed = graphene.Field(lambda: FeedObject) 35 | 36 | @staticmethod 37 | def mutate(root, info, feed): 38 | feed = Feed.objects.create( 39 | user=info.context.user, post=feed.post) 40 | return CreateFeed(feed=feed, ok=True) 41 | 42 | 43 | class LikeFeed(graphene.Mutation): 44 | class Arguments: 45 | id = graphene.ID(required=True) 46 | 47 | feed = graphene.Field(lambda: FeedObject) 48 | 49 | @staticmethod 50 | def mutate(root, info, id): 51 | _, feed_id = from_global_id(id) 52 | feed = Feed.like(feed_id, info.context.user) 53 | return LikeFeed(feed=feed) 54 | -------------------------------------------------------------------------------- /bootcamp/feeds/static/css/feeds.css: -------------------------------------------------------------------------------- 1 | ul.stream { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | ul.stream li { 7 | list-style: none; 8 | border-bottom: 1px solid #eeeeee; 9 | padding: 1em 0; 10 | } 11 | 12 | ul.stream li:last-child { 13 | border-bottom: none; 14 | } 15 | 16 | ul.stream li a img.user { 17 | width: 60px; 18 | border-radius: 5px; 19 | float: left; 20 | } 21 | 22 | ul.stream li div.post { 23 | margin-left: 60px; 24 | padding: 0 0 0 1.2em; 25 | overflow-x: auto; 26 | } 27 | 28 | ul.stream li div.post h3 { 29 | font-size: 1em; 30 | margin: 0; 31 | margin-bottom: .2em; 32 | } 33 | 34 | ul.stream li div.post h3 small { 35 | margin-left: .3em; 36 | font-size: .8em; 37 | } 38 | 39 | ul.stream li div.post p { 40 | margin: 0; 41 | } 42 | 43 | ul.stream li div.post div.interaction { 44 | padding-top: .2em; 45 | } 46 | 47 | ul.stream li div.post div.interaction a { 48 | margin-right: .6em; 49 | font-size: .8em; 50 | } 51 | 52 | .stream-update { 53 | text-align: center; 54 | border-bottom: 1px solid #eeeeee; 55 | display: none; 56 | } 57 | 58 | .stream-update a { 59 | display: block; 60 | padding: .6em 0; 61 | background-color: #f5f8fa; 62 | } 63 | 64 | .stream-update a:hover { 65 | text-decoration: none; 66 | background-color: #e1e8ed; 67 | } 68 | 69 | .compose { 70 | display: none; 71 | border-bottom: 1px solid #eee; 72 | } 73 | 74 | .compose h2 { 75 | font-size: 1.4em; 76 | } 77 | 78 | .comments { 79 | margin-top: .6em; 80 | display: none; 81 | } 82 | 83 | .comments ol { 84 | margin: .8em 0 0; 85 | padding: .2em 0; 86 | background-color: #f4f4f4; 87 | border-radius: 3px; 88 | overflow-x: auto; 89 | } 90 | 91 | .comments ol li { 92 | list-style: none; 93 | padding: 0; 94 | } 95 | 96 | .comments ol li img.user-comment { 97 | width: 35px; 98 | border-radius: 4px; 99 | float: left; 100 | margin-left: 10px; 101 | } 102 | 103 | .comments ol li div { 104 | margin-left: 45px; 105 | padding: 0 .8em; 106 | font-size: .9em; 107 | } 108 | 109 | .comments ol li { 110 | padding: .6em .6em .6em 0; 111 | border-bottom: none; 112 | } 113 | 114 | .comments ol li h4 { 115 | margin: 0; 116 | margin-left: 45px; 117 | padding: 0 0 .2em .8em; 118 | font-size: .9em; 119 | } 120 | 121 | .comments ol li h4 small { 122 | margin-left: .3em; 123 | } 124 | 125 | .empty { 126 | margin: 0 .8em; 127 | font-size: .9em; 128 | } 129 | 130 | .load { 131 | text-align: center; 132 | padding-top: 1em; 133 | border-top: 1px solid #eeeeee; 134 | display: none; 135 | } 136 | 137 | .loadcomment { 138 | text-align: center; 139 | } 140 | 141 | .remove-feed { 142 | color: #bbbbbb; 143 | font-size: .8em; 144 | padding-top: .2em; 145 | float: right; 146 | display: none; 147 | cursor: pointer; 148 | } 149 | 150 | .remove-feed:hover { 151 | color: #333333; 152 | } -------------------------------------------------------------------------------- /bootcamp/feeds/templates/feeds/feed.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load i18n %} 4 | {% load humanize %} 5 | {% load static %} 6 | 7 | {% block title %}{% trans 'Feed' %}{% endblock %} 8 | 9 | {% block head %} 10 | 11 | 12 | 13 | {% endblock head %} 14 | 15 | {% block main %} 16 | 19 |
    20 | {% include 'feeds/partial_feed.html' with feed=feed %} 21 |
22 | {% endblock main %} 23 | -------------------------------------------------------------------------------- /bootcamp/feeds/templates/feeds/feeds.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block head %} 6 | 7 | 8 | 9 | 10 | {% endblock head %} 11 | 12 | {% block main %} 13 | 22 | {% if not user.is_anonymous %} 23 |
24 |

{% trans "Compose a new post" %}

25 | 26 |
27 | {% csrf_token %} 28 | 29 | 30 |
31 | 32 |
33 |
34 | 37 | 38 | 255 39 |
40 |
41 |
42 | {% endif %} 43 | 46 |
    47 | {% for feed in feeds %} 48 | {% include 'feeds/partial_feed.html' with feed=feed %} 49 | {% endfor %} 50 |
51 |
52 | 53 |
54 |
55 | 56 | 57 | 58 |
59 | {% endblock main %} 60 | -------------------------------------------------------------------------------- /bootcamp/feeds/templates/feeds/partial_feed.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load humanize %} 3 | 4 |
  • 5 | 6 | 7 |
    8 | 9 | {% if feed.user == user or user.is_superuser %} 10 | 11 | {% endif %} 12 | 13 |

    {{ feed.user.profile.get_screen_name }} 14 | {{ feed.date|naturaltime }} 15 |

    16 |

    {{ feed.post|safe }}

    17 | 18 | {% if not user.is_anonymous %} 19 | 38 |
    39 |
    40 | {% csrf_token %} 41 | 42 | 44 |
    45 |
      46 | {% comment %} Place holder to load feed comments {% endcomment %} 47 |
    48 |
    49 | {% endif %} 50 | 51 |
    52 |
  • 53 | -------------------------------------------------------------------------------- /bootcamp/feeds/templates/feeds/partial_feed_comments.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 | {% load i18n %} 3 | 4 | {% for comment in feed.get_comments %} 5 |
  • 6 | {% if comment.user == user %} 7 | 9 | {% endif %} 10 | 11 | 12 | 13 |

    14 | 15 | {{ comment.user.profile.get_screen_name }} 16 | 17 | {{ comment.date|naturaltime }} 18 |

    19 |
    {{ comment.post|safe }}
    20 |
  • 21 | {% empty %} 22 |
  • {% trans 'Be the first one to comment' %}
  • 23 | {% endfor %} 24 | -------------------------------------------------------------------------------- /bootcamp/feeds/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, Client 2 | from django.contrib.auth.models import User 3 | 4 | from .models import Feed 5 | 6 | 7 | class FeedViewsTest(TestCase): 8 | def setUp(self): 9 | self.client = Client() 10 | user = User.objects.create_user( 11 | username='test_user', 12 | email='lennon@thebeatles.com', 13 | password='test_password' 14 | ) 15 | self.feed = Feed.objects.create(user=user, post='test feed') 16 | 17 | def test_feeds(self): 18 | response = self.client.get('/feeds/') 19 | self.assertEqual(response.status_code, 200) 20 | 21 | def test_feed(self): 22 | response = self.client.get('/feeds/123/') 23 | self.assertEqual(response.status_code, 404) 24 | 25 | response = self.client.get(f'/feeds/{self.feed.pk}/') 26 | self.assertEqual(response.status_code, 200) 27 | -------------------------------------------------------------------------------- /bootcamp/feeds/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .import views 4 | 5 | urlpatterns = [ 6 | path('', views.feeds, name='feeds'), 7 | path('/', views.feed, name='feed'), 8 | path('post/', views.post, name='post'), 9 | path('like/', views.like, name='like'), 10 | path('load/', views.load, name='load'), 11 | path('check/', views.check, name='check'), 12 | path('update/', views.update, name='update'), 13 | path('comment/', views.comment, name='comment'), 14 | path('remove/', views.remove, name='remove_feed'), 15 | path('load_new/', views.load_new, name='load_new'), 16 | path('track_comments/', views.track_comments, name='track_comments'), 17 | ] 18 | -------------------------------------------------------------------------------- /bootcamp/feeds/views.py: -------------------------------------------------------------------------------- 1 | from django.http import ( 2 | HttpResponse, JsonResponse, 3 | HttpResponseForbidden, HttpResponseBadRequest, 4 | ) 5 | from django.template.loader import render_to_string 6 | from django.template.context_processors import csrf 7 | from django.shortcuts import render, get_object_or_404 8 | from django.contrib.auth.decorators import login_required 9 | from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger 10 | 11 | from .models import Feed 12 | 13 | FEEDS_NUM_PAGES = 10 14 | 15 | 16 | def feeds(request): 17 | all_feeds = Feed.get_feeds() 18 | paginator = Paginator(all_feeds, FEEDS_NUM_PAGES).page(1) 19 | from_feed = -1 20 | if paginator: 21 | from_feed = paginator[0].id 22 | return render(request, 'feeds/feeds.html', { 23 | 'feeds': paginator, 24 | 'from_feed': from_feed, 25 | 'page': 1, 26 | }) 27 | 28 | 29 | def feed(request, pk): 30 | feed = get_object_or_404(Feed, pk=pk) 31 | return render(request, 'feeds/feed.html', {'feed': feed}) 32 | 33 | 34 | def load(request): 35 | page = request.GET.get('page') 36 | from_feed = request.GET.get('from_feed') 37 | feed_source = request.GET.get('feed_source') 38 | csrf_token = str(csrf(request)['csrf_token']) 39 | 40 | all_feeds = Feed.get_feeds(from_feed) 41 | 42 | if feed_source != 'all': 43 | all_feeds = all_feeds.filter(user__id=feed_source) 44 | 45 | paginator = Paginator(all_feeds, FEEDS_NUM_PAGES) 46 | 47 | try: 48 | feeds = paginator.page(page) 49 | except PageNotAnInteger: 50 | return HttpResponseBadRequest() 51 | except EmptyPage: 52 | feeds = [] 53 | 54 | html = '' 55 | for feed in feeds: 56 | context = { 57 | 'feed': feed, 58 | 'user': request.user, 59 | 'csrf_token': csrf_token 60 | } 61 | template = render_to_string('feeds/partial_feed.html', context) 62 | 63 | html = f'{html}{template}' 64 | 65 | return HttpResponse(html) 66 | 67 | 68 | def _html_feeds(last_feed, user, csrf_token, feed_source='all'): 69 | feeds = Feed.get_feeds_after(last_feed) 70 | 71 | if feed_source != 'all': 72 | feeds = feeds.filter(user__id=feed_source) 73 | 74 | html = '' 75 | 76 | for feed in feeds: 77 | context = {'feed': feed, 'user': user, 'csrf_token': csrf_token} 78 | template = render_to_string('feeds/partial_feed.html', context) 79 | html = f'{html}{template}' 80 | return html 81 | 82 | 83 | def load_new(request): 84 | last_feed = request.GET.get('last_feed') 85 | user = request.user 86 | csrf_token = str(csrf(request)['csrf_token']) 87 | html = _html_feeds(last_feed, user, csrf_token) 88 | return HttpResponse(html) 89 | 90 | 91 | def check(request): 92 | last_feed = request.GET.get('last_feed') 93 | feed_source = request.GET.get('feed_source') 94 | feeds = Feed.get_feeds_after(last_feed) 95 | 96 | if feed_source != 'all': 97 | feeds = feeds.filter(user__id=feed_source) 98 | 99 | count = feeds.count() 100 | return HttpResponse(count) 101 | 102 | 103 | @login_required 104 | def post(request): 105 | last_feed = request.POST.get('last_feed') 106 | post = request.POST['post'].strip()[:255] 107 | user = request.user 108 | 109 | csrf_token = str(csrf(request)['csrf_token']) 110 | 111 | if len(post) > 0: 112 | Feed.objects.create( 113 | post=post, 114 | user=user 115 | ) 116 | html = _html_feeds(last_feed, user, csrf_token) 117 | return HttpResponse(html) 118 | 119 | 120 | @login_required 121 | def like(request): 122 | user = request.user 123 | feed_id = request.POST['feed'] 124 | 125 | feed = Feed.like(feed_id, user) 126 | return HttpResponse(feed.likes) 127 | 128 | 129 | @login_required 130 | def comment(request): 131 | if request.method == 'POST': 132 | feed_id = request.POST['feed'] 133 | feed = Feed.objects.get(pk=feed_id) 134 | post = request.POST['post'].strip() 135 | 136 | if len(post) > 0: 137 | post = post[:255] 138 | user = request.user 139 | feed.comment(user=user, post=post) 140 | user.profile.notify_commented(feed) 141 | user.profile.notify_also_commented(feed) 142 | 143 | context = {'feed': feed} 144 | return render(request, 'feeds/partial_feed_comments.html', context) 145 | 146 | feed_id = request.GET.get('feed') 147 | feed = Feed.objects.get(pk=feed_id) 148 | return render(request, 'feeds/partial_feed_comments.html', {'feed': feed}) 149 | 150 | 151 | @login_required 152 | def update(request): 153 | first_feed = request.GET.get('first_feed') 154 | last_feed = request.GET.get('last_feed') 155 | feed_source = request.GET.get('feed_source') 156 | 157 | feeds = Feed.get_feeds().filter(id__range=(last_feed, first_feed)) 158 | 159 | if feed_source != 'all': 160 | feeds = feeds.filter(user__id=feed_source) 161 | 162 | dump = {} 163 | 164 | for feed in feeds: 165 | dump[feed.pk] = {'likes': feed.likes, 'comments': feed.comments} 166 | 167 | return JsonResponse(dump, safe=False) 168 | 169 | 170 | @login_required 171 | def track_comments(request): 172 | feed_id = request.GET.get('feed') 173 | feed = Feed.objects.get(pk=feed_id) 174 | return render(request, 'feeds/partial_feed_comments.html', {'feed': feed}) 175 | 176 | 177 | @login_required 178 | def remove(request): 179 | feed_id = request.POST.get('feed') 180 | 181 | feed = Feed.objects.filter(pk=feed_id).first() 182 | 183 | if not feed: 184 | return HttpResponseBadRequest 185 | 186 | if feed.user == request.user or request.user.is_superuser: 187 | likes = feed.get_likes() 188 | parent = feed.parent 189 | 190 | for like in likes: 191 | like.delete() 192 | 193 | feed.delete() 194 | if parent: 195 | parent.calculate_comments() 196 | 197 | return HttpResponse() 198 | 199 | return HttpResponseForbidden() 200 | -------------------------------------------------------------------------------- /bootcamp/messenger/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/messenger/__init__.py -------------------------------------------------------------------------------- /bootcamp/messenger/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | from django.contrib import admin 3 | 4 | from .models import Message 5 | 6 | admin.site.register(Message) 7 | -------------------------------------------------------------------------------- /bootcamp/messenger/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-06 10:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Message', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('message', models.TextField(blank=True, max_length=1000)), 22 | ('date', models.DateTimeField(auto_now_add=True)), 23 | ('is_read', models.BooleanField(default=False)), 24 | ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 25 | ('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 26 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 27 | ], 28 | options={ 29 | 'verbose_name': 'Message', 30 | 'verbose_name_plural': 'Messages', 31 | 'ordering': ('date',), 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /bootcamp/messenger/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/messenger/migrations/__init__.py -------------------------------------------------------------------------------- /bootcamp/messenger/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Max 3 | from django.contrib.auth.models import User 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class Message(models.Model): 8 | user = models.ForeignKey(User, related_name='+', on_delete=models.CASCADE) 9 | message = models.TextField(max_length=1000, blank=True) 10 | date = models.DateTimeField(auto_now_add=True) 11 | conversation = models.ForeignKey(User, related_name='+', on_delete=models.CASCADE) 12 | from_user = models.ForeignKey(User, related_name='+', on_delete=models.CASCADE) 13 | is_read = models.BooleanField(default=False) 14 | 15 | class Meta: 16 | ordering = ('date',) 17 | verbose_name = _('Message') 18 | verbose_name_plural = _('Messages') 19 | 20 | def __str__(self): 21 | return self.message 22 | 23 | @staticmethod 24 | def send_message(from_user, to_user, message): 25 | message = message[:1000] 26 | current_user_message = Message( 27 | from_user=from_user, 28 | message=message, 29 | user=from_user, 30 | conversation=to_user, 31 | is_read=True 32 | ) 33 | current_user_message.save() 34 | Message.objects.create( 35 | from_user=from_user, 36 | conversation=from_user, 37 | message=message, 38 | user=to_user 39 | ) 40 | return current_user_message 41 | 42 | @staticmethod 43 | def get_conversations(user): 44 | conversations = Message.objects.filter(user=user).values( 45 | 'conversation').annotate(last=Max('date')).order_by('-last') 46 | 47 | users = [] 48 | for conversation in conversations: 49 | unread = Message.objects.filter( 50 | user=user, 51 | is_read=False, 52 | conversation__pk=conversation['conversation'], 53 | ).count(), 54 | 55 | users.append({ 56 | 'user': User.objects.get(pk=conversation['conversation']), 57 | 'last': conversation['last'], 58 | 'unread': unread, 59 | }) 60 | 61 | return users 62 | -------------------------------------------------------------------------------- /bootcamp/messenger/static/css/messages.css: -------------------------------------------------------------------------------- 1 | .conversation { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | .conversation li { 7 | list-style: none; 8 | padding: .6em 0; 9 | } 10 | 11 | .conversation li h5 { 12 | margin: 0; 13 | margin-bottom: .2em; 14 | } 15 | 16 | .conversation li div { 17 | margin-left: 50px; 18 | } 19 | 20 | .conversation .picture { 21 | width: 40px; 22 | border-radius: 5px; 23 | float: left; 24 | } 25 | 26 | .conversation-portrait { 27 | width: 20px; 28 | border-radius: 3px; 29 | margin-right: 5px; 30 | } 31 | 32 | .typeahead, .tt-query, .tt-hint { 33 | border: 1px solid #CCCCCC; 34 | border-radius: 5px; 35 | font-size: 1.2em; 36 | height: 35px; 37 | line-height: 35px; 38 | outline: medium none; 39 | padding: 4px 12px; 40 | width: 300px; 41 | } 42 | .typeahead { 43 | background-color: #FFFFFF; 44 | } 45 | .typeahead:focus { 46 | border: 2px solid #0097CF; 47 | } 48 | .tt-query { 49 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset; 50 | } 51 | .tt-hint { 52 | color: #999999; 53 | } 54 | .tt-dropdown-menu { 55 | background-color: #FFFFFF; 56 | border: 1px solid rgba(0, 0, 0, 0.2); 57 | border-radius: 8px; 58 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); 59 | margin-top: 8px; 60 | padding: 8px 0; 61 | width: 300px; 62 | } 63 | .tt-suggestion { 64 | font-size: 1.2em; 65 | line-height: 24px; 66 | padding: 3px 20px; 67 | } 68 | .tt-suggestion.tt-cursor { 69 | background-color: #0097CF; 70 | color: #FFFFFF; 71 | } 72 | .tt-suggestion p { 73 | margin: 0; 74 | } -------------------------------------------------------------------------------- /bootcamp/messenger/static/js/check_messages.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | function check_messages() { 3 | $.ajax({ 4 | url: '/messages/check/', 5 | cache: false, 6 | success: function (data) { 7 | $("#unread-count").text(data); 8 | }, 9 | complete: function () { 10 | window.setTimeout(check_messages, 60000); 11 | } 12 | }); 13 | }; 14 | check_messages(); 15 | }); -------------------------------------------------------------------------------- /bootcamp/messenger/static/js/messages.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $("#send").submit(function () { 3 | $.ajax({ 4 | url: '/messages/send/', 5 | data: $("#send").serialize(), 6 | cache: false, 7 | type: 'post', 8 | success: function (data) { 9 | $(".send-message").before(data); 10 | $("input[name='message']").val(''); 11 | $("input[name='message']").focus(); 12 | } 13 | }); 14 | return false; 15 | }); 16 | }); -------------------------------------------------------------------------------- /bootcamp/messenger/static/js/messages.typehead.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | var substringMatcher = function(strs) { 3 | return function findMatches(q, cb) { 4 | var matches, substringRegex; 5 | matches = []; 6 | substrRegex = new RegExp(q, 'i'); 7 | $.each(strs, function(i, str) { 8 | if (substrRegex.test(str)) { 9 | matches.push({ value: str }); 10 | } 11 | }); 12 | cb(matches); 13 | }; 14 | }; 15 | 16 | $.ajax({ 17 | url: '/messages/users/', 18 | cache: false, 19 | success: function (data) { 20 | $('#to').typeahead({ 21 | hint: true, 22 | highlight: true, 23 | minLength: 1 24 | }, 25 | { 26 | name: 'data', 27 | displayKey: 'value', 28 | source: substringMatcher(data) 29 | }); 30 | } 31 | }); 32 | }); -------------------------------------------------------------------------------- /bootcamp/messenger/templates/messages/base_messages.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block head %} 6 | 7 | 8 | 9 | 10 | {% endblock head %} 11 | 12 | 13 | {% block main %} 14 | 18 |
    19 |
    20 | {% include 'messages/includes/partial_conversations.html' with conversations=conversations active=active %} 21 |
    22 |
    23 | {% block container %} 24 | {% endblock container %} 25 |
    26 |
    27 | {% endblock main %} 28 | -------------------------------------------------------------------------------- /bootcamp/messenger/templates/messages/inbox.html: -------------------------------------------------------------------------------- 1 | {% extends 'messages/base_messages.html' %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans 'Inbox' %}{% endblock %} 5 | 6 | {% block page_header %}{% trans 'Inbox' %}{% endblock %} 7 | 8 | {% block container %} 9 | {% if messages %} 10 |
      11 | {% for message in messages %} 12 | {% include 'messages/includes/partial_message.html' with message=message %} 13 | {% endfor %} 14 |
    • 15 | 16 |
      17 |
      18 | {% csrf_token %} 19 | 20 | 21 |
      22 |
      23 |
    • 24 |
    25 | {% else %} 26 |

    {% trans 'Your inbox is empty!' %}

    27 | {% endif %} 28 | {% endblock container %} -------------------------------------------------------------------------------- /bootcamp/messenger/templates/messages/includes/partial_conversations.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | -------------------------------------------------------------------------------- /bootcamp/messenger/templates/messages/includes/partial_message.html: -------------------------------------------------------------------------------- 1 |
  • 2 | 3 |
    4 |
    5 | 6 | {{ message.date|date:'N d G:i' }} 7 | 8 | 9 | {{ message.from_user.profile.get_screen_name }} 10 | 11 |
    12 | {{ message.message }} 13 |
    14 |
  • -------------------------------------------------------------------------------- /bootcamp/messenger/templates/messages/new.html: -------------------------------------------------------------------------------- 1 | {% extends 'messages/base_messages.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block title %}{% trans 'New message' %}{% endblock %} 6 | 7 | {% block page_header %}{% trans 'New message' %}{% endblock %} 8 | 9 | {% block container %} 10 |
    11 | {% csrf_token %} 12 |
    13 | 14 |
    15 | 16 |
    17 |
    18 |
    19 | 20 |
    21 | 22 |
    23 |
    24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 | 31 | {% endblock container %} 32 | -------------------------------------------------------------------------------- /bootcamp/messenger/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, Client 2 | from django.contrib.auth.models import User 3 | 4 | 5 | class MessengerViewsTest(TestCase): 6 | def setUp(self): 7 | self.client = Client() 8 | User.objects.create_user( 9 | username='test_user', 10 | email='lennon@thebeatles.com', 11 | password='test_password' 12 | ) 13 | User.objects.create_user( 14 | username='test_user_1', 15 | email='lennon_1@thebeatles.com', 16 | password='test_password' 17 | ) 18 | self.client.login(username='test_user', password='test_password') 19 | 20 | def test_inbox(self): 21 | response = self.client.get('/messages/') 22 | self.assertEqual(response.status_code, 200) 23 | 24 | def test_messages(self): 25 | response = self.client.get('/messages/no_user/') 26 | self.assertEqual(response.status_code, 200) 27 | 28 | response = self.client.get('/messages/test_user/') 29 | self.assertEqual(response.status_code, 200) 30 | 31 | def test_new_message(self): 32 | response = self.client.get('/messages/new/') 33 | self.assertEqual(response.status_code, 200) 34 | 35 | response = self.client.post('/messages/new/', { 36 | 'to': 'test_user_1', 37 | 'message': 'test message' 38 | }) 39 | self.assertEqual(response.status_code, 302) 40 | self.assertEqual(response.url, '/messages/test_user_1/') 41 | -------------------------------------------------------------------------------- /bootcamp/messenger/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | 6 | urlpatterns = [ 7 | path('', views.inbox, name='inbox'), 8 | path('new/', views.new, name='new_message'), 9 | path('send/', views.send, name='send_message'), 10 | path('delete/', views.delete, name='delete_message'), 11 | path('users/', views.users, name='users_message'), 12 | path('check/', views.check, name='check_message'), 13 | path('/', views.messages, name='messages'), 14 | ] 15 | -------------------------------------------------------------------------------- /bootcamp/messenger/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.shortcuts import render, redirect 3 | from django.contrib.auth.decorators import login_required 4 | from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse 5 | 6 | from .models import Message 7 | 8 | 9 | @login_required 10 | def inbox(request): 11 | messages = None 12 | active_conversation = None 13 | 14 | conversations = Message.get_conversations(user=request.user) 15 | 16 | if conversations: 17 | conversation = conversations[0] 18 | active_conversation = conversation['user'].username 19 | 20 | messages = Message.objects.filter(user=request.user, 21 | conversation=conversation['user']) 22 | messages.update(is_read=True) 23 | 24 | for conversation in conversations: 25 | if conversation['user'].username == active_conversation: 26 | conversation['unread'] = 0 27 | context = { 28 | 'messages': messages, 29 | 'active': active_conversation, 30 | 'conversations': conversations 31 | } 32 | return render(request, 'messages/inbox.html', context) 33 | 34 | 35 | @login_required 36 | def messages(request, username): 37 | active_conversation = username 38 | conversations = Message.get_conversations(user=request.user) 39 | messages = Message.objects.filter(user=request.user, 40 | conversation__username=username) 41 | messages.update(is_read=True) 42 | for conversation in conversations: 43 | if conversation['user'].username == username: 44 | conversation['unread'] = 0 45 | 46 | context = { 47 | 'messages': messages, 48 | 'conversations': conversations, 49 | 'active': active_conversation 50 | } 51 | return render(request, 'messages/inbox.html', context) 52 | 53 | 54 | @login_required 55 | def new(request): 56 | if request.method == 'POST': 57 | to_user_username = request.POST.get('to') 58 | message = request.POST.get('message') 59 | 60 | to_user = User.objects.filter(username=to_user_username).first() 61 | 62 | if not to_user: 63 | return redirect('/messages/new/') 64 | 65 | if len(message.strip()) == 0: 66 | return redirect('/messages/new/') 67 | 68 | from_user = request.user 69 | if from_user != to_user: 70 | Message.send_message(from_user, to_user, message) 71 | 72 | return redirect(f'/messages/{to_user_username}/') 73 | 74 | conversations = Message.get_conversations(user=request.user) 75 | context = {'conversations': conversations} 76 | return render(request, 'messages/new.html', context) 77 | 78 | 79 | @login_required 80 | def delete(request): 81 | return HttpResponse() 82 | 83 | 84 | @login_required 85 | def send(request): 86 | if request.method == 'POST': 87 | message = request.POST.get('message') 88 | to_user_username = request.POST.get('to') 89 | 90 | to_user = User.objects.get(username=to_user_username) 91 | 92 | if len(message.strip()) == 0: 93 | return HttpResponse() 94 | 95 | from_user = request.user 96 | if from_user != to_user: 97 | msg = Message.send_message(from_user, to_user, message) 98 | return render(request, 'messages/includes/partial_message.html', 99 | {'message': msg}) 100 | return HttpResponse() 101 | 102 | return HttpResponseBadRequest() 103 | 104 | 105 | @login_required 106 | def users(request): 107 | users = User.objects.filter(is_active=True) 108 | 109 | dump = [] 110 | template = '{0} ({1})' 111 | 112 | for user in users: 113 | if user.profile.get_screen_name() != user.username: 114 | screen_name = user.profile.get_screen_name() 115 | dump.append(template.format(screen_name, user.username)) 116 | else: 117 | dump.append(user.username) 118 | 119 | return JsonResponse(dump, safe=False) 120 | 121 | 122 | @login_required 123 | def check(request): 124 | count = Message.objects.filter(user=request.user, is_read=False).count() 125 | return HttpResponse(count) 126 | -------------------------------------------------------------------------------- /bootcamp/middleware.py: -------------------------------------------------------------------------------- 1 | from django.core import signing 2 | from django.contrib.auth.models import User 3 | 4 | from django.utils.deprecation import MiddlewareMixin 5 | 6 | 7 | class JWTAuthenticationAndCORSMiddleware(MiddlewareMixin): 8 | def process_request(self, request): 9 | authorization: str = request.META.get('HTTP_AUTHORIZATION') 10 | if not authorization or not authorization.startswith('JWT'): 11 | return 12 | 13 | _, token = authorization.split() 14 | try: 15 | user_id = signing.loads(token) 16 | except signing.BadSignature: 17 | return 18 | 19 | if not user_id or not isinstance(user_id, int): 20 | return 21 | 22 | request.user = User.objects.get(id=user_id) 23 | 24 | def process_response(self, request, response): 25 | response['Access-Control-Allow-Origin'] = '*' 26 | return response 27 | 28 | -------------------------------------------------------------------------------- /bootcamp/questions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/questions/__init__.py -------------------------------------------------------------------------------- /bootcamp/questions/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | from django.contrib import admin 3 | 4 | from .models import Question, Answer, Tag 5 | 6 | admin.site.register(Question) 7 | admin.site.register(Answer) 8 | admin.site.register(Tag) 9 | -------------------------------------------------------------------------------- /bootcamp/questions/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .models import Question, Answer 5 | 6 | 7 | class QuestionForm(forms.ModelForm): 8 | title = forms.CharField( 9 | max_length=255, 10 | label=_('Title'), 11 | widget=forms.TextInput(attrs={'class': 'form-control'}) 12 | ) 13 | description = forms.CharField( 14 | max_length=2000, 15 | label=_('Description'), 16 | widget=forms.Textarea(attrs={'class': 'form-control'}), 17 | help_text=' ', 18 | ) 19 | tags = forms.CharField( 20 | max_length=255, 21 | required=False, 22 | label=_('Tags'), 23 | widget=forms.TextInput(attrs={'class': 'form-control'}), 24 | help_text=_('Use spaces to separate the tags, \ 25 | such as "asp.net mvc5 javascript"') 26 | ) 27 | 28 | class Meta: 29 | model = Question 30 | fields = ['title', 'description', 'tags'] 31 | 32 | 33 | class AnswerForm(forms.ModelForm): 34 | question = forms.ModelChoiceField(widget=forms.HiddenInput(), 35 | queryset=Question.objects.all()) 36 | description = forms.CharField( 37 | max_length=2000, 38 | widget=forms.Textarea(attrs={'class': 'form-control', 'rows': '4'}) 39 | ) 40 | 41 | class Meta: 42 | model = Answer 43 | fields = ['question', 'description'] 44 | -------------------------------------------------------------------------------- /bootcamp/questions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-06 10:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Question', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('title', models.CharField(max_length=255)), 22 | ('description', models.TextField(max_length=2000)), 23 | ('create_date', models.DateTimeField(auto_now_add=True)), 24 | ('update_date', models.DateTimeField(auto_now_add=True)), 25 | ('favorites', models.IntegerField(default=0)), 26 | ('has_accepted_answer', models.BooleanField(default=False)), 27 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 28 | ], 29 | options={ 30 | 'verbose_name': 'Question', 31 | 'verbose_name_plural': 'Questions', 32 | 'ordering': ('-update_date',), 33 | }, 34 | ), 35 | migrations.CreateModel( 36 | name='Tag', 37 | fields=[ 38 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 39 | ('tag', models.CharField(max_length=50)), 40 | ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questions.question')), 41 | ], 42 | options={ 43 | 'verbose_name': 'Tag', 44 | 'verbose_name_plural': 'Tags', 45 | }, 46 | ), 47 | migrations.CreateModel( 48 | name='Answer', 49 | fields=[ 50 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 51 | ('description', models.TextField(max_length=2000)), 52 | ('create_date', models.DateTimeField(auto_now_add=True)), 53 | ('update_date', models.DateTimeField(blank=True, null=True)), 54 | ('votes', models.IntegerField(default=0)), 55 | ('is_accepted', models.BooleanField(default=False)), 56 | ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questions.question')), 57 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 58 | ], 59 | options={ 60 | 'verbose_name': 'Answer', 61 | 'verbose_name_plural': 'Answers', 62 | 'ordering': ('-is_accepted', '-votes', 'create_date'), 63 | }, 64 | ), 65 | migrations.AddConstraint( 66 | model_name='tag', 67 | constraint=models.UniqueConstraint(fields=('tag', 'question'), name='unique_question_tag'), 68 | ), 69 | ] 70 | -------------------------------------------------------------------------------- /bootcamp/questions/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/questions/migrations/__init__.py -------------------------------------------------------------------------------- /bootcamp/questions/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | 6 | from bootcamp.activities.models import Activity 7 | 8 | 9 | class Question(models.Model): 10 | user = models.ForeignKey(User, on_delete=models.CASCADE) 11 | title = models.CharField(max_length=255) 12 | description = models.TextField(max_length=2000) 13 | create_date = models.DateTimeField(auto_now_add=True) 14 | update_date = models.DateTimeField(auto_now_add=True) 15 | favorites = models.IntegerField(default=0) 16 | has_accepted_answer = models.BooleanField(default=False) 17 | 18 | class Meta: 19 | verbose_name = 'Question' 20 | verbose_name_plural = 'Questions' 21 | ordering = ('-update_date',) 22 | 23 | def __str__(self): 24 | return self.title 25 | 26 | @staticmethod 27 | def get_unanswered(): 28 | return Question.objects.filter(has_accepted_answer=False) 29 | 30 | @staticmethod 31 | def get_answered(): 32 | return Question.objects.filter(has_accepted_answer=True) 33 | 34 | def get_answers(self): 35 | return Answer.objects.filter(question=self) 36 | 37 | def get_answers_count(self): 38 | return Answer.objects.filter(question=self).count() 39 | 40 | def get_accepted_answer(self): 41 | return Answer.objects.get(question=self, is_accepted=True) 42 | 43 | def get_description_as_markdown(self): 44 | return self.description 45 | 46 | def get_description_preview(self): 47 | preview_len = 255 48 | 49 | if len(self.description) > preview_len: 50 | return f'{self.description[:preview_len]}...' 51 | 52 | return self.description 53 | 54 | def get_description_preview_as_markdown(self): 55 | return self.get_description_preview() 56 | 57 | def calculate_favorites(self): 58 | favorites = Activity.objects.filter( 59 | question=self.pk, 60 | activity_type=Activity.FAVORITE 61 | ).count() 62 | 63 | self.favorites = favorites 64 | self.save() 65 | return self.favorites 66 | 67 | def get_favoriters(self): 68 | favorites = Activity.objects.filter( 69 | question=self.pk, 70 | activity_type=Activity.FAVORITE 71 | ) 72 | 73 | favoriters = [favorite.user for favorite in favorites] 74 | return favoriters 75 | 76 | def create_tags(self, tags): 77 | tags = tags.strip() 78 | tag_list = tags.split(' ') 79 | 80 | for tag in tag_list: 81 | Tag.objects.get_or_create(tag=tag.lower(), question=self) 82 | 83 | def get_tags(self): 84 | return Tag.objects.filter(question=self) 85 | 86 | 87 | class Answer(models.Model): 88 | user = models.ForeignKey(User, on_delete=models.CASCADE) 89 | question = models.ForeignKey(Question, on_delete=models.CASCADE) 90 | description = models.TextField(max_length=2000) 91 | create_date = models.DateTimeField(auto_now_add=True) 92 | update_date = models.DateTimeField(null=True, blank=True) 93 | votes = models.IntegerField(default=0) 94 | is_accepted = models.BooleanField(default=False) 95 | 96 | class Meta: 97 | verbose_name = 'Answer' 98 | verbose_name_plural = 'Answers' 99 | ordering = ('-is_accepted', '-votes', 'create_date',) 100 | 101 | def __str__(self): 102 | return self.description 103 | 104 | def accept(self): 105 | answers = Answer.objects.filter(question=self.question) 106 | 107 | for answer in answers: 108 | answer.is_accepted = False 109 | answer.save() 110 | 111 | self.is_accepted = True 112 | self.save() 113 | self.question.has_accepted_answer = True 114 | self.question.save() 115 | 116 | def calculate_votes(self): 117 | up_votes = Activity.objects.filter( 118 | answer=self.pk, 119 | activity_type=Activity.UP_VOTE, 120 | ).count() 121 | 122 | down_votes = Activity.objects.filter( 123 | answer=self.pk, 124 | activity_type=Activity.DOWN_VOTE, 125 | ).count() 126 | 127 | self.votes = up_votes - down_votes 128 | self.save() 129 | return self.votes 130 | 131 | def get_up_voters(self): 132 | votes = Activity.objects.filter(answer=self.pk, 133 | activity_type=Activity.UP_VOTE,) 134 | voters = [vote.user for vote in votes] 135 | return voters 136 | 137 | def get_down_voters(self): 138 | votes = Activity.objects.filter(answer=self.pk, 139 | activity_type=Activity.DOWN_VOTE) 140 | voters = [vote.user for vote in votes] 141 | return voters 142 | 143 | def get_description_as_markdown(self): 144 | return self.description 145 | 146 | 147 | class Tag(models.Model): 148 | tag = models.CharField(max_length=50) 149 | question = models.ForeignKey(Question, on_delete=models.CASCADE) 150 | 151 | class Meta: 152 | verbose_name = 'Tag' 153 | verbose_name_plural = 'Tags' 154 | 155 | constraints = [ 156 | models.UniqueConstraint(fields=('tag', 'question'), name='unique_question_tag') 157 | ] 158 | 159 | def __str__(self): 160 | return self.tag 161 | -------------------------------------------------------------------------------- /bootcamp/questions/static/css/questions.css: -------------------------------------------------------------------------------- 1 | .questions { 2 | margin-top: 1em; 3 | } 4 | 5 | .questions .pagination { 6 | margin: 0; 7 | } 8 | 9 | .questions .user { 10 | width: 25px; 11 | border-radius: 3px; 12 | } 13 | 14 | .questions .asked { 15 | color: #aaaaaa; 16 | font-size: .8em; 17 | } 18 | 19 | .questions .username { 20 | margin-left: .3em; 21 | font-weight: 500; 22 | font-size: 0.8em; 23 | } 24 | 25 | .questions .panel-body:hover { 26 | background-color: #f5f8fa; 27 | cursor: pointer; 28 | } 29 | 30 | .questions .question-user { 31 | margin-bottom: .6em; 32 | } 33 | 34 | .options { 35 | text-align: center; 36 | 37 | } 38 | 39 | .options span { 40 | display: block; 41 | font-size: 1.2em; 42 | } 43 | 44 | .options span.favorite { 45 | font-size: 3em; 46 | margin-top: .4em; 47 | } 48 | 49 | .options span.vote { 50 | font-size: 2em; 51 | color: #ddd; 52 | } 53 | 54 | .options span.vote:hover { 55 | cursor: pointer; 56 | color: #428bca; 57 | } 58 | 59 | .options span.voted { 60 | color: #333; 61 | } 62 | 63 | .options span.accept { 64 | font-size: 2.6em; 65 | color: #dddddd; 66 | margin-top: .2em; 67 | } 68 | 69 | .options span.accept:hover { 70 | cursor: pointer; 71 | color: #5cb85c; 72 | } 73 | 74 | .options span.accepted { 75 | color: #5cb85c; 76 | } 77 | 78 | .answer { 79 | margin-top: 1em; 80 | } 81 | 82 | .answer .answer-user, .question .question-user { 83 | margin-bottom: .6em; 84 | } 85 | 86 | .answer .answer-user .user, .question .question-user .user { 87 | width: 40px; 88 | border-radius: 5px; 89 | } 90 | 91 | .answer .answer-user .username, .question .question-user .username { 92 | margin-left: .4em; 93 | } 94 | 95 | .answer .answered, .question .asked { 96 | color: #aaaaaa; 97 | margin-left: .6em; 98 | } 99 | 100 | .favorite { 101 | cursor: pointer; 102 | } 103 | 104 | .favorite:hover { 105 | color: #f0ad4e; 106 | } 107 | 108 | .favorited { 109 | color: #f0ad4e; 110 | } 111 | 112 | .question-info { 113 | text-align: center; 114 | float: right; 115 | padding: 0 1em; 116 | } 117 | 118 | .question-info h5 { 119 | margin: 0; 120 | margin-bottom: .2em; 121 | } 122 | 123 | .question-info .info:first-child { 124 | margin-bottom: 1.2em; 125 | } -------------------------------------------------------------------------------- /bootcamp/questions/static/js/questions.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $(".question .panel-body").click(function () { 3 | var question_id = $(this).closest(".question").attr("question-id"); 4 | location.href = "/questions/" + question_id; 5 | }); 6 | 7 | $(".accept").click(function () { 8 | var span = $(this); 9 | var question = $(".question").attr("question-id"); 10 | var answer = $(this).closest(".answer").attr("answer-id"); 11 | var csrf = $("input[name='csrfmiddlewaretoken']", $(this).closest(".answer")).val(); 12 | $.ajax({ 13 | url: '/questions/answer/accept/', 14 | data: { 15 | 'question': question, 16 | 'answer': answer, 17 | 'csrfmiddlewaretoken': csrf 18 | }, 19 | type: 'post', 20 | cache: false, 21 | success: function (data) { 22 | $(".accept").removeClass("accepted"); 23 | $(".accept").prop("title", "Click to accept the answer"); 24 | $(span).addClass("accepted"); 25 | $(span).prop("title", "Click to unaccept the answer"); 26 | } 27 | }); 28 | }); 29 | 30 | $(".vote").click(function () { 31 | var span = $(this); 32 | var answer = $(this).closest(".answer").attr("answer-id"); 33 | var csrf = $("input[name='csrfmiddlewaretoken']", $(this).closest(".answer")).val(); 34 | var vote = ""; 35 | if ($(this).hasClass("voted")) { 36 | var vote = "R"; 37 | } 38 | else if ($(this).hasClass("up-vote")) { 39 | vote = "U"; 40 | } 41 | else if ($(this).hasClass("down-vote")) { 42 | vote = "D"; 43 | } 44 | $.ajax({ 45 | url: '/questions/answer/vote/', 46 | data: { 47 | 'answer': answer, 48 | 'vote': vote, 49 | 'csrfmiddlewaretoken': csrf 50 | }, 51 | type: 'post', 52 | cache: false, 53 | success: function (data) { 54 | var options = $(span).closest('.options'); 55 | $('.vote', options).removeClass('voted'); 56 | if (vote == 'U' || vote == 'D') { 57 | $(span).addClass('voted'); 58 | } 59 | $('.votes', options).text(data); 60 | } 61 | }); 62 | }); 63 | 64 | $(".favorite").click(function () { 65 | var span = $(this); 66 | var question = $(this).closest(".question").attr("question-id"); 67 | var csrf = $("input[name='csrfmiddlewaretoken']", $(this).closest(".question")).val(); 68 | 69 | $.ajax({ 70 | url: '/questions/favorite/', 71 | data: { 72 | 'question': question, 73 | 'csrfmiddlewaretoken': csrf 74 | }, 75 | type: 'post', 76 | cache: false, 77 | success: function (data) { 78 | if ($(span).hasClass("favorited")) { 79 | $(span).removeClass("glyphicon-star") 80 | .removeClass("favorited") 81 | .addClass("glyphicon-star-empty"); 82 | } 83 | else { 84 | $(span).removeClass("glyphicon-star-empty") 85 | .addClass("glyphicon-star") 86 | .addClass("favorited"); 87 | } 88 | $(".favorite-count").text(data); 89 | } 90 | }); 91 | 92 | }); 93 | }); -------------------------------------------------------------------------------- /bootcamp/questions/templates/questions/ask.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block main %} 5 | 9 |
    10 | {% csrf_token %} 11 | {% for field in form.visible_fields %} 12 |
    13 | 14 |
    15 | {% if field.help_text == ' ' %} 16 | {% include 'markdown_editor.html' with textarea='id_description' %} 17 | {% endif %} 18 | {{ field }} 19 | {% if field.help_text %} 20 | {{ field.help_text }} 21 | {% endif %} 22 | {% for error in field.errors %} 23 | 24 | {% endfor %} 25 |
    26 |
    27 | {% endfor %} 28 |
    29 |
    30 | 31 | {% trans 'Cancel' %} 32 |
    33 |
    34 |
    35 | {% endblock main %} 36 | -------------------------------------------------------------------------------- /bootcamp/questions/templates/questions/partial_answer.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load humanize %} 3 | 4 |
    5 | {% csrf_token %} 6 |
    7 | 8 | {{ answer.votes }} 9 | 10 | {% if answer.is_accepted and user == question.user %} 11 | 12 | {% elif answer.is_accepted %} 13 | 14 | {% elif user == question.user %} 15 | 16 | {% endif %} 17 |
    18 |
    19 |
    20 | 21 | {{ answer.user.profile.get_screen_name }} 22 | {% trans "answered" %} {{ answer.create_date|naturaltime }} 23 |
    24 |
    25 | {{ answer.get_description_as_markdown|safe }} 26 |
    27 |
    28 |
    29 |
    30 | -------------------------------------------------------------------------------- /bootcamp/questions/templates/questions/partial_question.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 | {% load i18n %} 3 | 4 |
    5 |
    6 |

    7 | {% if question.has_accepted_answer %} 8 | 9 | {% endif %} 10 | {{ question.title }} 11 |

    12 |
    13 |
    14 |
    15 |
    16 |
    {% trans 'Answers' %}
    17 | {{ question.get_answers_count }} 18 |
    19 |
    20 |
    {% trans 'Favorites' %}
    21 | {{ question.favorites }} 22 |
    23 |
    24 |
    25 | 26 | {{ question.user.profile.get_screen_name }} 27 | {% trans 'asked' %} {{ question.update_date|naturaltime }} 28 |
    29 |
    30 | {{ question.get_description_preview_as_markdown|safe }} 31 |
    32 | {% if question.get_tags %} 33 |

    34 | {% for tag in question.get_tags %} 35 | {{ tag }} 36 | {% endfor %} 37 |

    38 | {% endif %} 39 |
    40 |
    -------------------------------------------------------------------------------- /bootcamp/questions/templates/questions/question.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% load humanize %} 6 | 7 | {% block head %} 8 | 9 | 10 | {% endblock head %} 11 | 12 | {% block main %} 13 | 17 |
    18 | {% csrf_token %} 19 |
    20 | {% if user.is_anonymous %} 21 | 23 | {% elif user in question.get_favoriters %} 24 | 26 | {% else %} 27 | 29 | {% endif %} 30 | {{ question.favorites }} 31 |
    32 |
    33 |

    {{ question.title }}

    34 | 35 |
    36 | 38 | {{ question.user.profile.get_screen_name }} 40 | {% trans 'asked' %} {{ question.update_date|naturaltime }} 41 |
    42 |
    43 | {{ question.get_description_as_markdown|safe }} 44 |
    45 | {% if question.get_tag_list %} 46 |

    47 | {% for tag in question.get_tag_list %} 48 | {{ tag }} 49 | {% endfor %} 50 |

    51 | {% endif %} 52 |
    53 |
    54 | 55 |
    56 | {% for answer in question.get_answers %} 57 | {% include 'questions/partial_answer.html' with question=question answer=answer %} 58 | {% endfor %} 59 | 60 | {% if not user.is_anonymous %} 61 |

    {% trans 'Your Answer' %}

    62 | 63 |
    64 | {% csrf_token %} 65 | {{ form.question }} 66 |
    67 | {% include 'markdown_editor.html' with textarea='id_description' %} 68 | {{ form.description }} 69 |
    70 |
    71 | 72 |
    73 |
    74 | {% endif %} 75 |
    76 | {% endblock main %} 77 | -------------------------------------------------------------------------------- /bootcamp/questions/templates/questions/questions.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block title %}{% trans 'Questions' %}{% endblock %} 6 | 7 | {% block head %} 8 | 9 | 10 | {% endblock head %} 11 | 12 | {% block main %} 13 | 21 |
    22 | 30 | {% for question in questions %} 31 | {% include 'questions/partial_question.html' with question=question %} 32 | {% empty %} 33 |

    {% trans "No question to display" %}

    34 | {% endfor %} 35 | {% include 'paginator.html' with paginator=questions %} 36 |
    37 | {% endblock main %} 38 | -------------------------------------------------------------------------------- /bootcamp/questions/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.contrib.auth.models import User 3 | from django.db.models.query import QuerySet 4 | 5 | from .models import Question, Answer 6 | 7 | 8 | class QuestionsMethodTests(TestCase): 9 | def setUp(self): 10 | user = User.objects.create_user( 11 | username='john', 12 | email='lennon@thebeatles.com', 13 | password='johnpassword' 14 | ) 15 | self.question = Question.objects.create( 16 | user=user, 17 | title='test title', 18 | description='test decorators', 19 | ) 20 | Answer.objects.create( 21 | user=user, 22 | question=self.question, 23 | description='test answers decorators', 24 | is_accepted=True, 25 | ) 26 | 27 | def test_get_answers(self): 28 | answers = self.question.get_answers() 29 | self.assertIsInstance(answers, QuerySet) 30 | 31 | answers_count = self.question.get_answers_count() 32 | self.assertIsInstance(answers_count, int) 33 | 34 | accept_answer = self.question.get_accepted_answer() 35 | self.assertIsInstance(accept_answer, Answer) 36 | 37 | def test_get_favorites(self): 38 | favorites_count = self.question.calculate_favorites() 39 | self.assertEqual(favorites_count, 0) 40 | 41 | favoriters = self.question.get_favoriters() 42 | self.assertIsInstance(favoriters, list) 43 | 44 | def test_tags(self): 45 | tags = 'Python Tornado Async' 46 | self.question.create_tags(tags) 47 | 48 | tag_list = self.question.get_tags() 49 | self.assertIsInstance(tag_list, QuerySet) 50 | 51 | expect_tag = ('python', 'tornado', 'async') 52 | for tag in tag_list: 53 | self.assertIn(tag.tag, expect_tag) 54 | 55 | 56 | class AnswerMethodTests(TestCase): 57 | def setUp(self): 58 | user = User.objects.create_user( 59 | username='john_1', 60 | email='lennon_1@thebeatles.com', 61 | password='johnpassword' 62 | ) 63 | question = Question.objects.create( 64 | user=user, 65 | title='test title 1', 66 | description='test decorators 1', 67 | ) 68 | self.answer = Answer.objects.create( 69 | user=user, 70 | question=question, 71 | description='test answers decorators 1', 72 | is_accepted=True, 73 | ) 74 | 75 | def test_get_vote(self): 76 | self.answer.accept() 77 | 78 | votes_count = self.answer.calculate_votes() 79 | self.assertIsInstance(votes_count, int) 80 | 81 | up_voters = self.answer.get_up_voters() 82 | self.assertIsInstance(up_voters, list) 83 | 84 | down_voters = self.answer.get_down_voters() 85 | self.assertIsInstance(down_voters, list) 86 | -------------------------------------------------------------------------------- /bootcamp/questions/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('', views.questions, name='questions'), 7 | path('/', views.question, name='question'), 8 | path('ask/', views.ask, name='ask'), 9 | path('all/', views.all_question, name='all'), 10 | path('answered/', views.answered, name='answered'), 11 | path('unanswered/', views.unanswered, name='unanswered'), 12 | path('favorite/', views.favorite, name='favorite'), 13 | path('answer/', views.answer, name='answer'), 14 | path('answer/accept/', views.accept, name='accept'), 15 | path('answer/vote/', views.vote, name='vote'), 16 | ] 17 | -------------------------------------------------------------------------------- /bootcamp/questions/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.contrib.auth.decorators import login_required 3 | from django.http import HttpResponse, HttpResponseForbidden 4 | from django.shortcuts import render, redirect, get_object_or_404 5 | from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger 6 | 7 | from bootcamp.activities.models import Activity 8 | 9 | from .models import Question, Answer 10 | from .forms import QuestionForm, AnswerForm 11 | 12 | 13 | def _questions(request, questions, active): 14 | paginator = Paginator(questions, 10) 15 | page = request.GET.get('page') 16 | 17 | try: 18 | questions = paginator.page(page) 19 | except PageNotAnInteger: 20 | questions = paginator.page(1) 21 | except EmptyPage: 22 | questions = paginator.page(paginator.num_pages) 23 | 24 | context = {'questions': questions, 'active': active} 25 | return render(request, 'questions/questions.html', context) 26 | 27 | 28 | def questions(request): 29 | return unanswered(request) 30 | 31 | 32 | def answered(request): 33 | questions = Question.get_answered() 34 | return _questions(request, questions, 'answered') 35 | 36 | 37 | def unanswered(request): 38 | questions = Question.get_unanswered() 39 | return _questions(request, questions, 'unanswered') 40 | 41 | 42 | def all_question(request): 43 | questions = Question.objects.all() 44 | return _questions(request, questions, 'all') 45 | 46 | 47 | @login_required 48 | def ask(request): 49 | if request.method == 'POST': 50 | form = QuestionForm(request.POST) 51 | if not form.is_valid(): 52 | return render(request, 'questions/ask.html', {'form': form}) 53 | 54 | question = Question() 55 | question.user = request.user 56 | question.title = form.cleaned_data.get('title') 57 | question.description = form.cleaned_data.get('description') 58 | question.save() 59 | 60 | tags = form.cleaned_data.get('tags') 61 | question.create_tags(tags) 62 | return redirect('/questions/') 63 | else: 64 | form = QuestionForm() 65 | 66 | return render(request, 'questions/ask.html', {'form': form}) 67 | 68 | 69 | def question(request, pk): 70 | question = get_object_or_404(Question, pk=pk) 71 | form = AnswerForm(initial={'question': question}) 72 | context = {'question': question, 'form': form} 73 | return render(request, 'questions/question.html', context) 74 | 75 | 76 | @login_required 77 | def answer(request): 78 | if request.method == 'POST': 79 | form = AnswerForm(request.POST) 80 | if form.is_valid(): 81 | user = request.user 82 | answer = Answer() 83 | answer.user = request.user 84 | answer.question = form.cleaned_data.get('question') 85 | answer.description = form.cleaned_data.get('description') 86 | answer.save() 87 | user.profile.notify_answered(answer.question) 88 | return redirect(f'/questions/{answer.question.pk}/') 89 | else: 90 | question = form.cleaned_data.get('question') 91 | context = {'question': question, 'form': form} 92 | return render(request, 'questions/question.html', context) 93 | else: 94 | return redirect('/questions/') 95 | 96 | 97 | @login_required 98 | def accept(request): 99 | answer_id = request.POST['answer'] 100 | answer = Answer.objects.get(pk=answer_id) 101 | user = request.user 102 | 103 | # answer.accept cleans previous accepted answer 104 | user.profile.unotify_accepted(answer.question.get_accepted_answer()) 105 | 106 | if answer.question.user == user: 107 | answer.accept() 108 | user.profile.notify_accepted(answer) 109 | return HttpResponse() 110 | else: 111 | return HttpResponseForbidden() 112 | 113 | 114 | @login_required 115 | def vote(request): 116 | answer_id = request.POST['answer'] 117 | answer = Answer.objects.get(pk=answer_id) 118 | vote = request.POST['vote'] 119 | user = request.user 120 | 121 | activity = Activity.objects.filter( 122 | Q(activity_type=Activity.UP_VOTE) | 123 | Q(activity_type=Activity.DOWN_VOTE), 124 | user=user, 125 | answer=answer_id, 126 | ) 127 | 128 | if activity: 129 | activity.delete() 130 | 131 | if vote in [Activity.UP_VOTE, Activity.DOWN_VOTE]: 132 | activity = Activity(activity_type=vote, user=user, answer=answer_id) 133 | activity.save() 134 | 135 | return HttpResponse(answer.calculate_votes()) 136 | 137 | 138 | @login_required 139 | def favorite(request): 140 | question_id = request.POST['question'] 141 | question = Question.objects.get(pk=question_id) 142 | user = request.user 143 | 144 | activity = Activity.objects.filter( 145 | user=user, 146 | question=question_id, 147 | activity_type=Activity.FAVORITE, 148 | ) 149 | 150 | if activity: 151 | activity.delete() 152 | user.profile.unotify_favorited(question) 153 | else: 154 | activity = Activity( 155 | user=user, 156 | question=question_id, 157 | activity_type=Activity.FAVORITE, 158 | ) 159 | activity.save() 160 | user.profile.notify_favorited(question) 161 | 162 | return HttpResponse(question.calculate_favorites()) 163 | -------------------------------------------------------------------------------- /bootcamp/schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from .feeds.schema import FeedQuery, CreateFeed, LikeFeed 4 | from .authentication.schema import UserQuery, CreateToken 5 | 6 | 7 | class Query(FeedQuery, UserQuery, graphene.ObjectType): 8 | node = graphene.Node.Field() 9 | 10 | 11 | class Mutations(graphene.ObjectType): 12 | create_feed = CreateFeed.Field() 13 | like_feed = LikeFeed.Field() 14 | create_token = CreateToken.Field() 15 | 16 | 17 | schema = graphene.Schema(query=Query, mutation=Mutations) 18 | -------------------------------------------------------------------------------- /bootcamp/search/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/search/__init__.py -------------------------------------------------------------------------------- /bootcamp/search/static/css/search.css: -------------------------------------------------------------------------------- 1 | .results ul { 2 | padding: 0; 3 | } 4 | 5 | .results ul li { 6 | list-style: none; 7 | padding: .8em .4em; 8 | border-bottom: 1px solid #eeeeee; 9 | } 10 | 11 | .results ul li:last-child { 12 | border-bottom: none; 13 | } 14 | 15 | .results ul li img { 16 | width: 45px; 17 | border-radius: 5px; 18 | } 19 | 20 | .feed-results h3 { 21 | margin: 0; 22 | font-size: 1.2em; 23 | } 24 | 25 | .feed-results .post { 26 | margin-left: 55px; 27 | padding-top: .2em; 28 | } 29 | 30 | .feed-results .post p { 31 | margin: 0; 32 | margin-top: .2em; 33 | } 34 | 35 | [class$='-results'] li:hover { 36 | cursor: pointer; 37 | background-color: #f5f8fa; 38 | } 39 | 40 | .info { 41 | margin-bottom: .5em; 42 | color: #a0a0a0; 43 | } 44 | 45 | .info a { 46 | color: #a0a0a0; 47 | } 48 | 49 | .info > span { 50 | margin-right: 1em; 51 | } 52 | 53 | .info .user img { 54 | width: 20px; 55 | border-radius: 3px; 56 | } 57 | 58 | .no-result { 59 | margin-top: 2em; 60 | } 61 | 62 | .result-user { 63 | width: 20px; 64 | border-radius: 4px; 65 | margin-right: .2em; 66 | } 67 | 68 | .results { 69 | margin-top: 1em; 70 | } 71 | 72 | .article-content ul, 73 | .article-content ol, 74 | .question-description ul, 75 | .question-description ol { 76 | padding-left: 20px; 77 | } 78 | 79 | .article-content ul li, 80 | .question-description ul li { 81 | border-bottom: none; 82 | list-style: disc; 83 | padding: 0; 84 | } 85 | 86 | .article-content ol li, 87 | .question-description ol li { 88 | border-bottom: none; 89 | list-style: decimal; 90 | padding: 0; 91 | } -------------------------------------------------------------------------------- /bootcamp/search/static/js/search.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $(".feed-results li").click(function () { 3 | var feed = $(this).attr("feed-id"); 4 | location.href = "/feeds/" + feed + "/"; 5 | }); 6 | 7 | $(".articles-results li").click(function () { 8 | var article = $(this).attr("article-slug"); 9 | location.href = "/articles/" + article + "/"; 10 | }); 11 | 12 | $(".questions-results li").click(function () { 13 | var question = $(this).attr("question-id"); 14 | location.href = "/questions/" + question + "/"; 15 | }); 16 | 17 | }); -------------------------------------------------------------------------------- /bootcamp/search/templates/search/partial_articles_results.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

    {% trans 'Articles' %}

    4 | {% if results %} 5 | 23 | {% else %} 24 |

    {% trans 'No article found' %} :(

    25 | {% endif %} -------------------------------------------------------------------------------- /bootcamp/search/templates/search/partial_feed_results.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load humanize %} 3 | 4 |

    {% trans 'Feed' %}

    5 | {% if results %} 6 | 22 | {% else %} 23 |

    {% trans 'No feed found' %} :(

    24 | {% endif %} 25 | -------------------------------------------------------------------------------- /bootcamp/search/templates/search/partial_questions_results.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load humanize %} 3 | 4 |

    {% trans 'Questions' %}

    5 | {% if results %} 6 | 21 | {% else %} 22 |

    {% trans 'No question found' %} :(

    23 | {% endif %} -------------------------------------------------------------------------------- /bootcamp/search/templates/search/partial_results_menu.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | -------------------------------------------------------------------------------- /bootcamp/search/templates/search/partial_users_results.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

    {% trans 'Users' %}

    4 | {% if results %} 5 | 18 | {% else %} 19 |

    {% trans 'No user found' %} :(

    20 | {% endif %} -------------------------------------------------------------------------------- /bootcamp/search/templates/search/results.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load i18n %} 4 | {% load humanize %} 5 | {% load static %} 6 | 7 | {% block title %} Search {% endblock %} 8 | 9 | {% block head %} 10 | 11 | 12 | {% endblock head %} 13 | 14 | {% block main %} 15 | 33 |
    34 |
    35 | {% include 'search/partial_results_menu.html' with active=active count=count querystring=querystring %} 36 |
    37 |
    38 | {% if active == 'feed' %} 39 | {% include 'search/partial_feed_results.html' with results=results %} 40 | {% elif active == 'articles' %} 41 | {% include 'search/partial_articles_results.html' with results=results %} 42 | {% elif active == 'questions' %} 43 | {% include 'search/partial_questions_results.html' with results=results %} 44 | {% elif active == 'users' %} 45 | {% include 'search/partial_users_results.html' with results=results %} 46 | {% endif %} 47 |
    48 |
    49 | {% endblock main %} 50 | -------------------------------------------------------------------------------- /bootcamp/search/templates/search/search.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load i18n %} 4 | {% load humanize %} 5 | {% load static %} 6 | 7 | {% block title %} Search {% endblock %} 8 | 9 | {% block head %} 10 | 11 | {% endblock head %} 12 | 13 | {% block main %} 14 | 17 | 18 |
    19 |
    20 |

    {% trans "Search Feed, Articles, Questions and Users" %}

    21 |
    22 |
    23 | 24 | 25 | 26 | 27 |
    28 |
    29 |
    30 |
    31 | {% endblock main %} 32 | -------------------------------------------------------------------------------- /bootcamp/search/tests.py: -------------------------------------------------------------------------------- 1 | # Create your tests here. 2 | -------------------------------------------------------------------------------- /bootcamp/search/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.contrib.auth.models import User 3 | from django.shortcuts import render, redirect 4 | 5 | from bootcamp.feeds.models import Feed 6 | from bootcamp.articles.models import Article 7 | from bootcamp.questions.models import Question 8 | 9 | 10 | def search(request): 11 | if 'q' not in request.GET: 12 | return render(request, 'search/search.html', {'hide_search': True}) 13 | 14 | querystring = request.GET.get('q').strip() 15 | search_type = request.GET.get('type') 16 | 17 | if len(querystring) == 0: 18 | return redirect('/search/') 19 | 20 | if search_type not in ['feed', 'articles', 'questions', 'users']: 21 | search_type = 'feed' 22 | 23 | results = { 24 | 'feed': Feed.objects.filter( 25 | post__icontains=querystring, parent=None), 26 | 'articles': Article.objects.filter( 27 | Q(title__icontains=querystring) | 28 | Q(content__icontains=querystring)), 29 | 'questions': Question.objects.filter( 30 | Q(title__icontains=querystring) | 31 | Q(description__icontains=querystring)), 32 | 'users': User.objects.filter( 33 | Q(username__icontains=querystring) | 34 | Q(first_name__icontains=querystring) | 35 | Q(last_name__icontains=querystring)) 36 | } 37 | count = { 38 | 'feed': results['feed'].count(), 39 | 'users': results['users'].count(), 40 | 'articles': results['articles'].count(), 41 | 'questions': results['questions'].count() 42 | } 43 | context = { 44 | 'hide_search': True, 45 | 'querystring': querystring, 46 | 'active': search_type, 47 | 'count': count, 48 | 'results': results[search_type] 49 | } 50 | return render(request, 'search/results.html', context) 51 | -------------------------------------------------------------------------------- /bootcamp/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for bootcamp project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.1.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | PROJECT_ROOT = Path(__file__).resolve().parent 18 | BASE_DIR = PROJECT_ROOT.parent 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = 'django-insecure-9of492%_4i#f8gff&9o)ge&wz4n5hvk653a8)yo#-w=6!ehce_' 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = 'DEBUG' in os.environ 29 | 30 | ALLOWED_HOSTS = ['*'] 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'django.contrib.humanize', 42 | 43 | 'bootcamp.activities', 44 | 'bootcamp.articles', 45 | 'bootcamp.authentication', 46 | 'bootcamp.core', 47 | 'bootcamp.feeds', 48 | 'bootcamp.messenger', 49 | 'bootcamp.questions', 50 | 'bootcamp.search', 51 | 52 | 'graphene_django', 53 | ] 54 | 55 | MIDDLEWARE = [ 56 | 'django.middleware.security.SecurityMiddleware', 57 | 'django.contrib.sessions.middleware.SessionMiddleware', 58 | 'django.middleware.common.CommonMiddleware', 59 | 'django.middleware.csrf.CsrfViewMiddleware', 60 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 61 | 'django.contrib.messages.middleware.MessageMiddleware', 62 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 63 | 'django.middleware.gzip.GZipMiddleware', 64 | 'django.middleware.http.ConditionalGetMiddleware', 65 | 'bootcamp.middleware.JWTAuthenticationAndCORSMiddleware' 66 | ] 67 | 68 | ROOT_URLCONF = 'bootcamp.urls' 69 | 70 | TEMPLATES = [ 71 | { 72 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 73 | 'DIRS': [ 74 | PROJECT_ROOT.joinpath('templates'), 75 | ], 76 | 'APP_DIRS': True, 77 | 'OPTIONS': { 78 | 'context_processors': [ 79 | 'django.template.context_processors.debug', 80 | 'django.template.context_processors.request', 81 | 'django.contrib.auth.context_processors.auth', 82 | 'django.contrib.messages.context_processors.messages', 83 | ] 84 | }, 85 | }, 86 | ] 87 | 88 | WSGI_APPLICATION = 'bootcamp.wsgi.application' 89 | 90 | # Database 91 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases 92 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 93 | 94 | DATABASES = { 95 | 'default': { 96 | 'ENGINE': 'django.db.backends.sqlite3', 97 | 'NAME': BASE_DIR / 'db.sqlite3', 98 | } 99 | } 100 | 101 | 102 | if 'REDIS_URL' in os.environ: 103 | CACHES = { 104 | "default": { 105 | "BACKEND": "django.core.cache.backends.redis.RedisCache", 106 | "LOCATION": os.getenv('REDIS_URL'), 107 | } 108 | } 109 | 110 | # Password validation 111 | # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators 112 | 113 | AUTH_PASSWORD_VALIDATORS = [ 114 | { 115 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 116 | }, 117 | { 118 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 119 | }, 120 | { 121 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 122 | }, 123 | { 124 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 125 | }, 126 | ] 127 | 128 | 129 | # Internationalization 130 | # https://docs.djangoproject.com/en/5.1/topics/i18n/ 131 | 132 | USE_TZ = True 133 | TIME_ZONE = 'Asia/Shanghai' 134 | 135 | USE_I18N = True 136 | 137 | LANGUAGE_CODE = 'zh-Hans' 138 | 139 | LANGUAGES = ( 140 | ('en', 'English'), 141 | ('zh-Hans', 'Chinese') 142 | ) 143 | 144 | LOCALE_PATHS = ( 145 | PROJECT_ROOT.joinpath('locale'), 146 | ) 147 | 148 | # Static files (CSS, JavaScript, Images) 149 | # https://docs.djangoproject.com/en/5.1/howto/static-files/ 150 | 151 | STATIC_URL = '/static/' 152 | STATIC_ROOT = BASE_DIR.joinpath('staticfiles') 153 | 154 | STATICFILES_DIRS = ( 155 | PROJECT_ROOT.joinpath('static'), 156 | ) 157 | 158 | # Default primary key field type 159 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field 160 | 161 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 162 | 163 | # Logging 164 | # https://docs.djangoproject.com/en/5.1/topics/logging/ 165 | LOGGING = { 166 | 'version': 1, 167 | 'disable_existing_loggers': False, 168 | 'handlers': { 169 | 'console': { 170 | 'class': 'logging.StreamHandler', 171 | }, 172 | }, 173 | 'loggers': { 174 | 'django.db.backends': { 175 | 'handlers': ['console'], 176 | 'level': 'INFO', 177 | }, 178 | }, 179 | } 180 | 181 | 182 | LOGIN_URL = '/' 183 | LOGIN_REDIRECT_URL = '/feeds/' 184 | 185 | ALLOWED_SIGNUP_DOMAINS = ['*'] 186 | 187 | SESSION_ENGINE = 'django.contrib.sessions.backends.cache' 188 | 189 | 190 | # Where your Graphene schema lives 191 | GRAPHENE = { 192 | 'SCHEMA': 'bootcamp.schema.schema' 193 | } 194 | 195 | -------------------------------------------------------------------------------- /bootcamp/static/css/bootcamp.css: -------------------------------------------------------------------------------- 1 | @import url(//fonts.googleapis.com/css?family=Audiowide); 2 | 3 | body { 4 | background-color: #f6f6f6; 5 | padding-top: 70px; 6 | } 7 | 8 | header .navbar-brand { 9 | font-size: 1.4em; 10 | font-weight: 200; 11 | font-family: "Audiowide", cursive; 12 | } 13 | 14 | main .container { 15 | margin-bottom: 2em; 16 | border: 1px solid #ddd; 17 | background-color: #ffffff; 18 | padding-top: 1.2em; 19 | padding-bottom: 1.2em; 20 | border-radius: .3em; 21 | box-shadow: 0 1px 3px #cccccc; 22 | } 23 | 24 | .page-header { 25 | margin: 0; 26 | } 27 | 28 | .page-header h1 { 29 | margin: 0; 30 | font-weight: 100; 31 | font-size: 2em; 32 | } 33 | 34 | .no-data { 35 | text-align: center; 36 | padding: 1em 0; 37 | } 38 | 39 | #notifications { 40 | font-size: 1.5em; 41 | padding: 13px; 42 | color: #dddddd; 43 | } 44 | 45 | #notifications.new-notifications { 46 | color: #428bca; 47 | } 48 | 49 | .popover { 50 | max-width: 350px; 51 | width: 350px; 52 | } 53 | 54 | .popover ul { 55 | padding: 0; 56 | margin: 0; 57 | } 58 | 59 | .popover ul li { 60 | list-style: none; 61 | border-bottom: 1px solid #eeeeee; 62 | padding: .4em 0; 63 | } 64 | 65 | .popover ul li:last-child { 66 | border-bottom: none; 67 | } 68 | 69 | .popover ul li .user-picture { 70 | width: 45px; 71 | float: left; 72 | } 73 | 74 | .popover ul li p { 75 | font-size: .9em; 76 | padding: 0 0 0 .6em; 77 | margin-left: 45px; 78 | margin-bottom: 0; 79 | } 80 | 81 | .markdown { 82 | margin-bottom: .8em; 83 | } 84 | -------------------------------------------------------------------------------- /bootcamp/static/img/Jcrop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/static/img/Jcrop.gif -------------------------------------------------------------------------------- /bootcamp/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/static/img/favicon.png -------------------------------------------------------------------------------- /bootcamp/static/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/static/img/loading.gif -------------------------------------------------------------------------------- /bootcamp/static/img/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qulc/bootcamp/de3b68240df225260f0da159269d4d66dfe1fe87/bootcamp/static/img/user.png -------------------------------------------------------------------------------- /bootcamp/static/js/bootcamp.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $.fn.count = function (limit) { 3 | var length = limit - $(this).val().length; 4 | var form = $(this).closest("form"); 5 | if (length <= 0) { 6 | $(".form-group", form).addClass("has-error"); 7 | } 8 | else { 9 | $(".form-group", form).removeClass("has-error"); 10 | } 11 | $(".help-count", form).text(length); 12 | }; 13 | }); -------------------------------------------------------------------------------- /bootcamp/static/js/bootcamp.markdown.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | 3 | $.fn.markdown = function () { 4 | var _textarea = $(this); 5 | 6 | $(".markdown .btn-group button").click(function (e) { 7 | e.preventDefault(); 8 | var action = $(this).attr("ref"); 9 | var selection = $(_textarea).selection(); 10 | 11 | switch (action) { 12 | case "header": 13 | $(_textarea).selection("replace", {text: "# " + selection}); 14 | break; 15 | case "bold": 16 | $(_textarea).selection("replace", {text: "**" + selection + "**"}); 17 | break; 18 | case "italic": 19 | $(_textarea).selection("replace", {text: "_" + selection + "_"}); 20 | break; 21 | case "list": 22 | var selection_list = selection.split("\n"); 23 | var selection_list_result = ""; 24 | for (var i = 0 ; i < selection_list.length ; i++) { 25 | selection_list_result += "* " + selection_list[i] + "\n"; 26 | }; 27 | if (selection_list_result.length > 0) { 28 | selection_list_result = selection_list_result.substring(0, selection_list_result.length - 1); 29 | } 30 | $(_textarea).selection("replace", {text: selection_list_result}); 31 | break; 32 | case "link": 33 | $("#markdown_link_text").val(""); 34 | $("#markdown_url").val(""); 35 | $("#markdown_insert_link").modal("show"); 36 | break; 37 | case "picture": 38 | $("#markdown_picture_url").val(""); 39 | $("#markdown_alt_text").val(""); 40 | $("#markdown_insert_picture").modal("show"); 41 | break; 42 | case "indent-left": 43 | var selection_list = selection.split("\n"); 44 | var selection_list_result = ""; 45 | for (var i = 0; i < selection_list.length; i++) { 46 | selection_list_result += " " + selection_list[i] + "\n"; 47 | }; 48 | if (selection_list_result.length > 0) { 49 | selection_list_result = selection_list_result.substring(0, selection_list_result.length - 1); 50 | } 51 | $(_textarea).selection("replace", {text: selection_list_result}); 52 | break; 53 | case "indent-right": 54 | var selection_list = selection.split("\n"); 55 | var selection_list_result = ""; 56 | for (var i = 0; i < selection_list.length ; i++) { 57 | selection_list_result += selection_list[i].trim() + "\n"; 58 | }; 59 | if (selection_list_result.length > 0) { 60 | selection_list_result = selection_list_result.substring(0, selection_list_result.length - 1); 61 | } 62 | $(_textarea).selection("replace", {text: selection_list_result}); 63 | break; 64 | }; 65 | 66 | }); 67 | 68 | $(".add_link").click(function () { 69 | var selection = $(_textarea).selection(); 70 | var text = $("#markdown_link_text").val(); 71 | var url = $("#markdown_url").val(); 72 | var link = "[" + text + "](" + url + ")"; 73 | $(_textarea).selection("replace", {text: link}); 74 | $("#markdown_insert_link").modal("hide") 75 | }); 76 | 77 | $(".add_picture").click(function () { 78 | var selection = $(_textarea).selection(); 79 | var url = $("#markdown_picture_url").val(); 80 | var alt = $("#markdown_alt_text").val(); 81 | var picture = "![" + alt + "](" + url + ")"; 82 | $(_textarea).selection("replace", {text: picture}); 83 | $("#markdown_insert_picture").modal("hide") 84 | }); 85 | 86 | }; 87 | }); -------------------------------------------------------------------------------- /bootcamp/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block main %} 5 | 8 |

    {% trans 'Hey you!' %}

    9 | {% endblock main %} -------------------------------------------------------------------------------- /bootcamp/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block title %} 404 {% endblock title %} 5 | 6 | {% block main %} 7 | 10 |

    {% trans 'We could not find the page you are looking for :(' %}

    11 | {% endblock %} -------------------------------------------------------------------------------- /bootcamp/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block main %} 5 | 8 |

    {% trans "Ooops! Something went wrong. That's all we know." %}

    9 | {% endblock main %} -------------------------------------------------------------------------------- /bootcamp/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load static %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block title %}Bootcamp{% endblock %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% block head %} {% endblock head %} 18 | 19 | 20 | {% block body %} 21 |
    22 | 82 |
    83 |
    84 |
    85 | {% block main %} 86 | {% endblock main %} 87 |
    88 |
    89 | 90 | 91 | {% endblock body %} 92 | 93 | 94 | -------------------------------------------------------------------------------- /bootcamp/templates/markdown_editor.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load static %} 3 | 4 | 5 | 6 | 7 |
    8 | 32 | 56 | 72 |
    73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
    82 | 83 |
    84 | 85 | -------------------------------------------------------------------------------- /bootcamp/templates/paginator.html: -------------------------------------------------------------------------------- 1 |
      2 | {% if paginator.has_previous %} 3 |
    • «
    • 4 | {% else %} 5 |
    • «
    • 6 | {% endif %} 7 | {% for i in paginator.paginator.page_range %} 8 | {% if paginator.number == i %} 9 |
    • {{ i }} (current)
    • 10 | {% else %} 11 |
    • {{ i }}
    • 12 | {% endif %} 13 | {% endfor %} 14 | {% if paginator.has_next %} 15 |
    • »
    • 16 | {% else %} 17 |
    • »
    • 18 | {% endif %} 19 |
    -------------------------------------------------------------------------------- /bootcamp/urls.py: -------------------------------------------------------------------------------- 1 | """bootcamp URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/5.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls import include 18 | from django.conf.urls.static import static 19 | from django.contrib import admin 20 | from django.contrib.auth import views as auth_views 21 | from django.urls import path 22 | from django.views.decorators.csrf import csrf_exempt 23 | from graphene_django.views import GraphQLView 24 | 25 | from .authentication import views as authentication_views 26 | from .core import views as core_views 27 | from .search import views as search_views 28 | 29 | urlpatterns = [ 30 | path('admin/', admin.site.urls), 31 | 32 | path('', core_views.home, name='home'), 33 | path('login', auth_views.LoginView.as_view(template_name='core/cover.html'), name='login'), 34 | path('logout', auth_views.LogoutView.as_view(next_page='/'), name='logout'), 35 | path('signup/', authentication_views.signup, name='signup'), 36 | 37 | path('feeds/', include('bootcamp.feeds.urls')), 38 | path('settings/', include('bootcamp.core.urls')), 39 | path('articles/', include('bootcamp.articles.urls')), 40 | path('messages/', include('bootcamp.messenger.urls')), 41 | path('questions/', include('bootcamp.questions.urls')), 42 | path('notifications/', include('bootcamp.activities.urls')), 43 | 44 | path('search/', search_views.search, name='search'), 45 | path('network/', core_views.network, name='network'), 46 | 47 | path('graphql', csrf_exempt(GraphQLView.as_view(graphiql=True))), 48 | 49 | path('/', core_views.profile, name='profile'), 50 | ] 51 | 52 | if settings.DEBUG: 53 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 54 | -------------------------------------------------------------------------------- /bootcamp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for ab project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ 8 | """ 9 | import os 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bootcamp.settings") 14 | 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bootcamp.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | graphene-django 3 | --------------------------------------------------------------------------------