From a25e0cae8f77b7dcae89a80564080f99d973c72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 30 Jun 2019 18:31:23 +0200 Subject: [PATCH 01/50] fix typo in README --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 5c960ac5d..80b4086f0 100755 --- a/README.rst +++ b/README.rst @@ -28,7 +28,7 @@ The project has four basic apps: * News (A Twitter-like microblog) * Articles (A collaborative blog) * Question & Answers (A Stack Overflow-like platform) -* Messeger (A basic chat-a-like tool for asynchronous communication.) +* Messenger (A basic chat-a-like tool for asynchronous communication.) Technology Stack ---------------- From 887d24978f8ecf7053a9fcaabfcc455332aeeaca Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Thu, 11 Jul 2019 15:48:38 -0500 Subject: [PATCH 02/50] What kind of sorcery is this, JS should indent with two spaces, stop doing it VSCode. --- bootcamp/static/js/articles.js | 28 +-- bootcamp/static/js/messager.js | 192 +++++++++---------- bootcamp/static/js/news.js | 332 ++++++++++++++++----------------- bootcamp/static/js/qa.js | 218 +++++++++++----------- 4 files changed, 385 insertions(+), 385 deletions(-) diff --git a/bootcamp/static/js/articles.js b/bootcamp/static/js/articles.js index a10deb9c8..4b7d675b4 100755 --- a/bootcamp/static/js/articles.js +++ b/bootcamp/static/js/articles.js @@ -1,18 +1,18 @@ $(function () { - $(".publish").click(function () { - $("input[name='status']").val("P"); - $("#article-form").submit(); - }); + $(".publish").click(function () { + $("input[name='status']").val("P"); + $("#article-form").submit(); + }); - $(".update").click(function () { - $("input[name='status']").val("P"); - //$("input[name='edited']").prop("checked"); - $("input[name='edited']").val("True"); - $("#article-form").submit(); - }); + $(".update").click(function () { + $("input[name='status']").val("P"); + //$("input[name='edited']").prop("checked"); + $("input[name='edited']").val("True"); + $("#article-form").submit(); + }); - $(".draft").click(function () { - $("input[name='status']").val("D"); - $("#article-form").submit(); - }); + $(".draft").click(function () { + $("input[name='status']").val("D"); + $("#article-form").submit(); + }); }); diff --git a/bootcamp/static/js/messager.js b/bootcamp/static/js/messager.js index a1c1b8aa6..41298454e 100755 --- a/bootcamp/static/js/messager.js +++ b/bootcamp/static/js/messager.js @@ -1,113 +1,113 @@ $(function () { - function setUserOnlineOffline(username, status) { - /* This function enables the client to switch the user connection - status, allowing to show if an user is connected or not. - */ - var elem = $("online-stat-" + username); - if (elem) { - if (status === 'online') { - elem.attr("class", "btn btn-success btn-circle"); - } else { - elem.attr("class", "btn btn-danger btn-circle"); - }; - }; + function setUserOnlineOffline(username, status) { + /* This function enables the client to switch the user connection + status, allowing to show if an user is connected or not. + */ + var elem = $("online-stat-" + username); + if (elem) { + if (status === 'online') { + elem.attr("class", "btn btn-success btn-circle"); + } else { + elem.attr("class", "btn btn-danger btn-circle"); + }; }; + }; - function addNewMessage(message_id) { - /* This function calls the respective AJAX view, so it will be able to - load the received message in a proper way. - */ - $.ajax({ - url: '/messages/receive-message/', - data: {'message_id': message_id}, - cache: false, - success: function (data) { - $(".send-message").before(data); - scrollConversationScreen(); - } - }); - }; + function addNewMessage(message_id) { + /* This function calls the respective AJAX view, so it will be able to + load the received message in a proper way. + */ + $.ajax({ + url: '/messages/receive-message/', + data: { 'message_id': message_id }, + cache: false, + success: function (data) { + $(".send-message").before(data); + scrollConversationScreen(); + } + }); + }; - function scrollConversationScreen() { - /* Set focus on the input box from the form, and rolls to show the - the most recent message. - */ - $("input[name='message']").focus(); - $('.conversation').scrollTop($('.conversation')[0].scrollHeight); - } + function scrollConversationScreen() { + /* Set focus on the input box from the form, and rolls to show the + the most recent message. + */ + $("input[name='message']").focus(); + $('.conversation').scrollTop($('.conversation')[0].scrollHeight); + } - $("#send").submit(function () { - $.ajax({ - url: '/messages/send-message/', - data: $("#send").serialize(), - cache: false, - type: 'POST', - success: function (data) { - $(".send-message").before(data); - $("input[name='message']").val(''); - scrollConversationScreen(); - } - }); - return false; + $("#send").submit(function () { + $.ajax({ + url: '/messages/send-message/', + data: $("#send").serialize(), + cache: false, + type: 'POST', + success: function (data) { + $(".send-message").before(data); + $("input[name='message']").val(''); + scrollConversationScreen(); + } }); + return false; + }); - // WebSocket connection management block. - // Correctly decide between ws:// and wss:// - var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws"; - var ws_path = ws_scheme + '://' + window.location.host + "/" + currentUser + "/"; - var webSocket = new channels.WebSocketBridge(); - webSocket.connect(ws_path); + // WebSocket connection management block. + // Correctly decide between ws:// and wss:// + var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws"; + var ws_path = ws_scheme + '://' + window.location.host + "/" + currentUser + "/"; + var webSocket = new channels.WebSocketBridge(); + webSocket.connect(ws_path); - window.onbeforeunload = function () { - // Small function to run instruction just before closing the session. - payload = { - "type": "recieve", - "sender": currentUser, - "set_status": "offline" - }; - webSocket.send(payload); - } + window.onbeforeunload = function () { + // Small function to run instruction just before closing the session. + payload = { + "type": "recieve", + "sender": currentUser, + "set_status": "offline" + }; + webSocket.send(payload); + } - // Helpful debugging - webSocket.socket.onopen = function () { - console.log("Connected to inbox stream"); - // Commenting this block until I find a better way to manage how to - // report the user status. + // Helpful debugging + webSocket.socket.onopen = function () { + console.log("Connected to inbox stream"); + // Commenting this block until I find a better way to manage how to + // report the user status. - /* payload = { - "type": "recieve", - "sender": currentUser, - "set_status": "online" - }; - webSocket.send(payload); */ + /* payload = { + "type": "recieve", + "sender": currentUser, + "set_status": "online" }; + webSocket.send(payload); */ + }; - webSocket.socket.onclose = function () { - console.log("Disconnected from inbox stream"); - }; + webSocket.socket.onclose = function () { + console.log("Disconnected from inbox stream"); + }; - // onmessage management. - webSocket.listen(function(event) { - switch (event.key) { - case "message": - if (event.sender === activeUser) { - addNewMessage(event.message_id); - // I hope there is a more elegant way to work this out. - setTimeout(function(){$("#unread-count").hide()}, 1); - } else { - $("#new-message-" + event.sender).show(); - } - break; + // onmessage management. + webSocket.listen(function (event) { + switch (event.key) { + case "message": + if (event.sender === activeUser) { + addNewMessage(event.message_id); + // I hope there is a more elegant way to work this out. + setTimeout(function () { $("#unread-count").hide() }, 1); + } else { + $("#new-message-" + event.sender).show(); + } + break; - case "set_status": - setUserOnlineOffline(event.sender, event.status); - break; + case "set_status": + setUserOnlineOffline(event.sender, event.status); + break; - default: - console.log('error: ', event); - console.log(typeof(event)) - break; - } - }); + default: + console.log('error: ', event); + console.log(typeof (event)) + break; + } + }); }); diff --git a/bootcamp/static/js/news.js b/bootcamp/static/js/news.js index 79780a041..96e71a579 100755 --- a/bootcamp/static/js/news.js +++ b/bootcamp/static/js/news.js @@ -1,192 +1,192 @@ $(function () { - function hide_stream_update() { - $(".stream-update").hide(); - }; + function hide_stream_update() { + $(".stream-update").hide(); + }; - function getCookie(name) { - // Function to get any cookie available in the session. - var cookieValue = null; - if (document.cookie && document.cookie !== '') { - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var cookie = jQuery.trim(cookies[i]); - // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } + function getCookie(name) { + // Function to get any cookie available in the session. + var cookieValue = null; + if (document.cookie && document.cookie !== '') { + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = jQuery.trim(cookies[i]); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; } - return cookieValue; - }; - - function csrfSafeMethod(method) { - // These HTTP methods do not require CSRF protection - return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); + } } + return cookieValue; + }; - var csrftoken = getCookie('csrftoken'); - var page_title = $(document).attr("title"); - // This sets up every ajax call with proper headers. - $.ajaxSetup({ - beforeSend: function(xhr, settings) { - if (!csrfSafeMethod(settings.type) && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } - } - }); + function csrfSafeMethod(method) { + // These HTTP methods do not require CSRF protection + return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); + } - // Focus on the modal input by default. - $('#newsFormModal').on('shown.bs.modal', function () { - $('#newsInput').trigger('focus') - }); + var csrftoken = getCookie('csrftoken'); + var page_title = $(document).attr("title"); + // This sets up every ajax call with proper headers. + $.ajaxSetup({ + beforeSend: function (xhr, settings) { + if (!csrfSafeMethod(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken); + } + } + }); - $('#newsThreadModal').on('shown.bs.modal', function () { - $('#replyInput').trigger('focus') - }); + // Focus on the modal input by default. + $('#newsFormModal').on('shown.bs.modal', function () { + $('#newsInput').trigger('focus') + }); - // Counts textarea characters to provide data to user. - $("#newsInput").keyup(function () { - var charCount = $(this).val().length; - $("#newsCounter").text(280 - charCount); - }); + $('#newsThreadModal').on('shown.bs.modal', function () { + $('#replyInput').trigger('focus') + }); - $("#replyInput").keyup(function () { - var charCount = $(this).val().length; - $("#replyCounter").text(280 - charCount); - }); + // Counts textarea characters to provide data to user. + $("#newsInput").keyup(function () { + var charCount = $(this).val().length; + $("#newsCounter").text(280 - charCount); + }); - $("input, textarea").attr("autocomplete", "off"); + $("#replyInput").keyup(function () { + var charCount = $(this).val().length; + $("#replyCounter").text(280 - charCount); + }); - $("#postNews").click(function () { - // Ajax call after pushing button, to register a News object. - $.ajax({ - url: '/news/post-news/', - data: $("#postNewsForm").serialize(), - type: 'POST', - cache: false, - success: function (data) { - $("ul.stream").prepend(data); - $("#newsInput").val(""); - $("#newsFormModal").modal("hide"); - hide_stream_update(); - }, - error : function(data){ - alert(data.responseText); - }, - }); + $("input, textarea").attr("autocomplete", "off"); + + $("#postNews").click(function () { + // Ajax call after pushing button, to register a News object. + $.ajax({ + url: '/news/post-news/', + data: $("#postNewsForm").serialize(), + type: 'POST', + cache: false, + success: function (data) { + $("ul.stream").prepend(data); + $("#newsInput").val(""); + $("#newsFormModal").modal("hide"); + hide_stream_update(); + }, + error: function (data) { + alert(data.responseText); + }, }); + }); - $("#replyNews").click(function () { - // Ajax call to register a reply to any given News object. - $.ajax({ - url: '/news/post-comment/', - data: $("#replyNewsForm").serialize(), - type: 'POST', - cache: false, - success: function (data) { - $("#replyInput").val(""); - $("#newsThreadModal").modal("hide"); - }, - error: function(data){ - alert(data.responseText); - }, - }); + $("#replyNews").click(function () { + // Ajax call to register a reply to any given News object. + $.ajax({ + url: '/news/post-comment/', + data: $("#replyNewsForm").serialize(), + type: 'POST', + cache: false, + success: function (data) { + $("#replyInput").val(""); + $("#newsThreadModal").modal("hide"); + }, + error: function (data) { + alert(data.responseText); + }, }); + }); - $("ul.stream").on("click", ".like", function () { - // Ajax call on action on like button. - var li = $(this).closest("li"); - var news = $(li).attr("news-id"); - payload = { - 'news': news, - 'csrf_token': csrftoken + $("ul.stream").on("click", ".like", function () { + // Ajax call on action on like button. + var li = $(this).closest("li"); + var news = $(li).attr("news-id"); + payload = { + 'news': news, + 'csrf_token': csrftoken + } + $.ajax({ + url: '/news/like/', + data: payload, + type: 'POST', + cache: false, + success: function (data) { + $(".like .like-count", li).text(data.likes); + if ($(".like .heart", li).hasClass("fa fa-heart")) { + $(".like .heart", li).removeClass("fa fa-heart"); + $(".like .heart", li).addClass("fa fa-heart-o"); + } else { + $(".like .heart", li).removeClass("fa fa-heart-o"); + $(".like .heart", li).addClass("fa fa-heart"); } - $.ajax({ - url: '/news/like/', - data: payload, - type: 'POST', - cache: false, - success: function (data) { - $(".like .like-count", li).text(data.likes); - if ($(".like .heart", li).hasClass("fa fa-heart")) { - $(".like .heart", li).removeClass("fa fa-heart"); - $(".like .heart", li).addClass("fa fa-heart-o"); - } else { - $(".like .heart", li).removeClass("fa fa-heart-o"); - $(".like .heart", li).addClass("fa fa-heart"); - } - } - }); - return false; + } }); + return false; + }); - $("ul.stream").on("click", ".comment", function () { - // Ajax call to request a given News object detail and thread, and to - // show it in a modal. - var post = $(this).closest(".card"); - var news = $(post).closest("li").attr("news-id"); - $("#newsThreadModal").modal("show"); - $.ajax({ - url: '/news/get-thread/', - data: {'news': news}, - cache: false, - beforeSend: function () { - $("#threadContent").html("
  • "); - }, - success: function (data) { - $("input[name=parent]").val(data.uuid) - $("#newsContent").html(data.news); - $("#threadContent").html(data.thread); - } - }); - return false; + $("ul.stream").on("click", ".comment", function () { + // Ajax call to request a given News object detail and thread, and to + // show it in a modal. + var post = $(this).closest(".card"); + var news = $(post).closest("li").attr("news-id"); + $("#newsThreadModal").modal("show"); + $.ajax({ + url: '/news/get-thread/', + data: { 'news': news }, + cache: false, + beforeSend: function () { + $("#threadContent").html("
  • "); + }, + success: function (data) { + $("input[name=parent]").val(data.uuid) + $("#newsContent").html(data.news); + $("#threadContent").html(data.thread); + } }); + return false; + }); }); /* Example query for the GraphQL endpoint. - query{ - news(uuidId: "--insert here the required uuid_id value for the lookup"){ - uuidId - content - timestamp - countThread - countLikers - user { - name - picture - } - liked { - name - } - thread{ - content - } - } - paginatedNews(page: 1){ - page - pages - hasNext - hasPrev - objects { - uuidId - content - timestamp - countThread - countLikers - user { - name - picture - } - liked { - name - } - thread{ - content - } - } - } + query{ + news(uuidId: "--insert here the required uuid_id value for the lookup"){ + uuidId + content + timestamp + countThread + countLikers + user { + name + picture + } + liked { + name + } + thread{ + content + } + } + paginatedNews(page: 1){ + page + pages + hasNext + hasPrev + objects { + uuidId + content + timestamp + countThread + countLikers + user { + name + picture + } + liked { + name + } + thread{ + content } + } + } + } */ diff --git a/bootcamp/static/js/qa.js b/bootcamp/static/js/qa.js index 2427fb0ad..49c052d1a 100755 --- a/bootcamp/static/js/qa.js +++ b/bootcamp/static/js/qa.js @@ -1,124 +1,124 @@ $(function () { - function getCookie(name) { - // Function to get any cookie available in the session. - var cookieValue = null; - if (document.cookie && document.cookie !== '') { - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var cookie = jQuery.trim(cookies[i]); - // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } + function getCookie(name) { + // Function to get any cookie available in the session. + var cookieValue = null; + if (document.cookie && document.cookie !== '') { + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = jQuery.trim(cookies[i]); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; } - return cookieValue; - }; - - function csrfSafeMethod(method) { - // These HTTP methods do not require CSRF protection - return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); + } } + return cookieValue; + }; - var csrftoken = getCookie('csrftoken'); - var page_title = $(document).attr("title"); - // This sets up every ajax call with proper headers. - $.ajaxSetup({ - beforeSend: function(xhr, settings) { - if (!csrfSafeMethod(settings.type) && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } - } - }); + function csrfSafeMethod(method) { + // These HTTP methods do not require CSRF protection + return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); + } - $("#publish").click(function () { - // function to operate the Publish button in the question form, marking - // the question status as published. - $("input[name='status']").val("O"); - $("#question-form").submit(); - }); + var csrftoken = getCookie('csrftoken'); + var page_title = $(document).attr("title"); + // This sets up every ajax call with proper headers. + $.ajaxSetup({ + beforeSend: function (xhr, settings) { + if (!csrfSafeMethod(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken); + } + } + }); - $("#draft").click(function () { - // Function to operate the Draft button in the question form, marking - // the question status as draft. - $("input[name='status']").val("D"); - $("#question-form").submit(); - }); + $("#publish").click(function () { + // function to operate the Publish button in the question form, marking + // the question status as published. + $("input[name='status']").val("O"); + $("#question-form").submit(); + }); + + $("#draft").click(function () { + // Function to operate the Draft button in the question form, marking + // the question status as draft. + $("input[name='status']").val("D"); + $("#question-form").submit(); + }); - $(".question-vote").click(function () { - // Vote on a question. - var span = $(this); - var question = $(this).closest(".question").attr("question-id"); - vote = null; - if ($(this).hasClass("up-vote")) { - vote = "U"; - } else { - vote = "D"; + $(".question-vote").click(function () { + // Vote on a question. + var span = $(this); + var question = $(this).closest(".question").attr("question-id"); + vote = null; + if ($(this).hasClass("up-vote")) { + vote = "U"; + } else { + vote = "D"; + } + $.ajax({ + url: '/qa/question/vote/', + data: { + 'question': question, + 'value': vote + }, + type: 'post', + cache: false, + success: function (data) { + $('.vote', span).removeClass('voted'); + if (vote === "U") { + $(span).addClass('voted'); } - $.ajax({ - url: '/qa/question/vote/', - data: { - 'question': question, - 'value': vote - }, - type: 'post', - cache: false, - success: function (data) { - $('.vote', span).removeClass('voted'); - if (vote === "U") { - $(span).addClass('voted'); - } - $("#questionVotes").text(data.votes); - } - }); + $("#questionVotes").text(data.votes); + } }); + }); - $(".answer-vote").click(function () { - // Vote on an answer. - var span = $(this); - var answer = $(this).closest(".answer").attr("answer-id"); - vote = null; - if ($(this).hasClass("up-vote")) { - vote = "U"; - } else { - vote = "D"; + $(".answer-vote").click(function () { + // Vote on an answer. + var span = $(this); + var answer = $(this).closest(".answer").attr("answer-id"); + vote = null; + if ($(this).hasClass("up-vote")) { + vote = "U"; + } else { + vote = "D"; + } + $.ajax({ + url: '/qa/answer/vote/', + data: { + 'answer': answer, + 'value': vote + }, + type: 'post', + cache: false, + success: function (data) { + $('.vote', span).removeClass('voted'); + if (vote === "U") { + $(span).addClass('voted'); } - $.ajax({ - url: '/qa/answer/vote/', - data: { - 'answer': answer, - 'value': vote - }, - type: 'post', - cache: false, - success: function (data) { - $('.vote', span).removeClass('voted'); - if (vote === "U") { - $(span).addClass('voted'); - } - $("#answerVotes").text(data.votes); - } - }); + $("#answerVotes").text(data.votes); + } }); + }); - $("#acceptAnswer").click(function () { - // Mark an answer as accepted. - var span = $(this); - var answer = $(this).closest(".answer").attr("answer-id"); - $.ajax({ - url: '/qa/accept-answer/', - data: { - 'answer': answer - }, - type: 'post', - cache: false, - success: function (data) { - $("#acceptAnswer").removeClass("accepted"); - $("#acceptAnswer").prop("title", "Click to accept the answer"); - $("#acceptAnswer").addClass("accepted"); - $("#acceptAnswer").prop("title", "Click to unaccept the answer"); - } - }); + $("#acceptAnswer").click(function () { + // Mark an answer as accepted. + var span = $(this); + var answer = $(this).closest(".answer").attr("answer-id"); + $.ajax({ + url: '/qa/accept-answer/', + data: { + 'answer': answer + }, + type: 'post', + cache: false, + success: function (data) { + $("#acceptAnswer").removeClass("accepted"); + $("#acceptAnswer").prop("title", "Click to accept the answer"); + $("#acceptAnswer").addClass("accepted"); + $("#acceptAnswer").prop("title", "Click to unaccept the answer"); + } }); + }); }); From 64265ff050730b501edd3c9e56f6dfeacfbc105e Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sat, 13 Jul 2019 10:43:49 -0500 Subject: [PATCH 03/50] Updating Redis version to the most recent one. --- README.rst | 4 ++-- dev.yml | 2 +- local.yml | 2 +- production.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 5c960ac5d..784583204 100755 --- a/README.rst +++ b/README.rst @@ -36,7 +36,7 @@ Technology Stack * Python_ 3.6.x / 3.7.x * `Django Web Framework`_ 1.11.x / 2.0.x * PostgreSQL_ -* `Redis 3.2`_ +* `Redis 5.0`_ * Daphne_ * Caddy_ * Docker_ @@ -52,7 +52,7 @@ Technology Stack .. _Python: https://www.python.org/ .. _`Django Web Framework`: https://www.djangoproject.com/ .. _PostgreSQL: https://www.postgresql.org/ -.. _`Redis 3.2`: https://redis.io/documentation +.. _`Redis 5.0`: https://redis.io/documentation .. _Daphne: https://github.com/django/daphne/ .. _Caddy: https://caddyserver.com/docs .. _Docker: https://docs.docker.com/ diff --git a/dev.yml b/dev.yml index c64cbefab..c08c8306f 100755 --- a/dev.yml +++ b/dev.yml @@ -14,6 +14,6 @@ services: - '5432:5432' redis: - image: redis:4.0 + image: redis:5.0 ports: - '6379:6379' diff --git a/local.yml b/local.yml index dc66f7e93..9e7e710c2 100755 --- a/local.yml +++ b/local.yml @@ -29,6 +29,6 @@ services: env_file: .env redis: - image: redis:4.0 + image: redis:5.0 ports: - '6379:6379' diff --git a/production.yml b/production.yml index aa68ff9de..491f7cb75 100755 --- a/production.yml +++ b/production.yml @@ -48,4 +48,4 @@ services: - "0.0.0.0:443:443" redis: - image: redis:4.0 + image: redis:5.0 From f9817df22209fffd47d8831847cbec35523ba375 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sat, 13 Jul 2019 11:14:01 -0500 Subject: [PATCH 04/50] Moving the GraphQL schema file structure. --- {bootcamp => config}/schema.py | 0 config/settings/base.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename {bootcamp => config}/schema.py (100%) diff --git a/bootcamp/schema.py b/config/schema.py similarity index 100% rename from bootcamp/schema.py rename to config/schema.py diff --git a/config/settings/base.py b/config/settings/base.py index cc925cc99..7868db8f4 100755 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -273,5 +273,5 @@ # GraphQL settings GRAPHENE = { - 'SCHEMA': 'bootcamp.schema.schema' + 'SCHEMA': 'bootcamp.config.schema.schema' } From fa82864d358877a682f5ddcfdca957e4df102745 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sat, 13 Jul 2019 11:18:36 -0500 Subject: [PATCH 05/50] Updating the documentation. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 784583204..fe92da39b 100755 --- a/README.rst +++ b/README.rst @@ -34,7 +34,7 @@ Technology Stack ---------------- * Python_ 3.6.x / 3.7.x -* `Django Web Framework`_ 1.11.x / 2.0.x +* `Django Web Framework`_ 2.0.x * PostgreSQL_ * `Redis 5.0`_ * Daphne_ From dc09936e1299b9afc147be775bceaa348971035f Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 14 Jul 2019 09:51:32 -0500 Subject: [PATCH 06/50] Fixing bad assignment. --- config/settings/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/settings/base.py b/config/settings/base.py index 7868db8f4..269d0f764 100755 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -273,5 +273,5 @@ # GraphQL settings GRAPHENE = { - 'SCHEMA': 'bootcamp.config.schema.schema' + 'SCHEMA': 'config.schema.schema' } From dea18280ae22642b79a4c6a87a0a05449fa07559 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 14 Jul 2019 09:52:25 -0500 Subject: [PATCH 07/50] Improving the schemas. --- bootcamp/news/schema.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bootcamp/news/schema.py b/bootcamp/news/schema.py index 1d1151815..faed1b39c 100755 --- a/bootcamp/news/schema.py +++ b/bootcamp/news/schema.py @@ -18,7 +18,13 @@ def resolve_count_thread(self, info, **kwargs): return self.get_thread().count() def resolve_count_likers(self, info, **kwargs): - return self.liked_news.count() + return self.get_likers().count() + + def resolve_get_thread(self, info, **kwargs): + return self.get_thread() + + def resolve_get_likers(self, info, **kwargs): + return self.get_likers() class NewsPaginatedType(graphene.ObjectType): From 337652a0679bb41036eecfed9f8bbc3a01bd9f78 Mon Sep 17 00:00:00 2001 From: mirza musharaf baig Date: Mon, 5 Aug 2019 15:39:37 +0500 Subject: [PATCH 08/50] chained comparison simplified --- bootcamp/news/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bootcamp/news/views.py b/bootcamp/news/views.py index a5babc798..ee2ca6aaf 100755 --- a/bootcamp/news/views.py +++ b/bootcamp/news/views.py @@ -36,7 +36,7 @@ def post_news(request): user = request.user post = request.POST['post'] post = post.strip() - if len(post) > 0 and len(post) <= 280: + if 0 < len(post) <= 280: posted = News.objects.create( user=user, content=post, @@ -50,9 +50,9 @@ def post_news(request): return HttpResponse(html) else: - lenght = len(post) - 280 + length = len(post) - 280 return HttpResponseBadRequest( - content=_(f'Text is {lenght} characters longer than accepted.')) + content=_(f'Text is {length} characters longer than accepted.')) @login_required From 3a8240ae70cddf93fae21dfad7637d906c728bc7 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Mon, 5 Aug 2019 07:00:41 -0500 Subject: [PATCH 09/50] Some black on the code, and an addition to the API to-be. --- bootcamp/news/models.py | 58 ++++++++++++++++++++++++----------------- bootcamp/news/schema.py | 21 ++++++++++++++- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/bootcamp/news/models.py b/bootcamp/news/models.py index 0f08a0889..b45cbfe51 100755 --- a/bootcamp/news/models.py +++ b/bootcamp/news/models.py @@ -15,17 +15,22 @@ class News(models.Model): """News model to contain small information snippets in the same manner as Twitter does.""" + user = models.ForeignKey( - settings.AUTH_USER_MODEL, null=True, related_name="publisher", - on_delete=models.SET_NULL) - parent = models.ForeignKey("self", blank=True, - null=True, on_delete=models.CASCADE, related_name="thread") + settings.AUTH_USER_MODEL, + null=True, + related_name="publisher", + on_delete=models.SET_NULL, + ) + parent = models.ForeignKey( + "self", blank=True, null=True, on_delete=models.CASCADE, related_name="thread" + ) timestamp = models.DateTimeField(auto_now_add=True) - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) content = models.TextField(max_length=280) - liked = models.ManyToManyField(settings.AUTH_USER_MODEL, - blank=True, related_name="liked_news") + liked = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="liked_news" + ) reply = models.BooleanField(verbose_name=_("Is a reply?"), default=False) class Meta: @@ -41,12 +46,11 @@ def save(self, *args, **kwargs): if not self.reply: channel_layer = get_channel_layer() payload = { - "type": "receive", - "key": "additional_news", - "actor_name": self.user.username - - } - async_to_sync(channel_layer.group_send)('notifications', payload) + "type": "receive", + "key": "additional_news", + "actor_name": self.user.username, + } + async_to_sync(channel_layer.group_send)("notifications", payload) def get_absolute_url(self): return reverse("news:detail", kwargs={"uuid_id": self.uuid}) @@ -57,10 +61,14 @@ def switch_like(self, user): else: self.liked.add(user) - notification_handler(user, self.user, - Notification.LIKED, action_object=self, - id_value=str(self.uuid_id), - key='social_update') + notification_handler( + user, + self.user, + Notification.LIKED, + action_object=self, + id_value=str(self.uuid_id), + key="social_update", + ) def get_parent(self): if self.parent: @@ -80,14 +88,16 @@ def reply_this(self, user, text): """ parent = self.get_parent() reply_news = News.objects.create( - user=user, - content=text, - reply=True, - parent=parent + user=user, content=text, reply=True, parent=parent ) notification_handler( - user, parent.user, Notification.REPLY, action_object=reply_news, - id_value=str(parent.uuid_id), key='social_update') + user, + parent.user, + Notification.REPLY, + action_object=reply_news, + id_value=str(parent.uuid_id), + key="social_update", + ) def get_thread(self): parent = self.get_parent() diff --git a/bootcamp/news/schema.py b/bootcamp/news/schema.py index faed1b39c..51ea37e41 100755 --- a/bootcamp/news/schema.py +++ b/bootcamp/news/schema.py @@ -8,6 +8,7 @@ class NewsType(DjangoObjectType): """DjangoObjectType to acces the News model.""" + count_thread = graphene.Int() count_likers = graphene.Int() @@ -30,6 +31,7 @@ def resolve_get_likers(self, info, **kwargs): class NewsPaginatedType(graphene.ObjectType): """A paginated type generic object to provide pagination to the news graph.""" + page = graphene.Int() pages = graphene.Int() has_next = graphene.Boolean() @@ -53,9 +55,26 @@ def resolve_paginated_news(self, info, page): return paginate_data(qs, page_size, page, NewsPaginatedType) def resolve_news(self, info, **kwargs): - uuid_id = kwargs.get('uuid_id') + uuid_id = kwargs.get("uuid_id") if uuid_id is not None: return News.objects.get(uuid_id=uuid_id) return None + + +class NewsMutation(graphene.Mutation): + """Mutation to create news objects on a efective way.""" + + class Arguments: + content = graphene.String() + user = graphene.ID() + parent = graphene.ID() + + content = graphene.String() + user = graphene.ID() + parent = graphene.ID() + news = graphene.Field(lambda: News) + + def mutate(self, **kwargs): + print(kwargs) From 0b4b5532b0157dcc825b5cbbe7cfe9064c9a0811 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 1 Sep 2019 09:42:35 -0500 Subject: [PATCH 10/50] Updating the requirements files structure. --- requirements/base.txt | 37 ++++++++++++++++++------------------- requirements/local.txt | 31 +++++++++++++++---------------- requirements/production.txt | 11 ++++++----- 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index bc90885e1..87cd54575 100755 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,30 +1,29 @@ # Python # ------------------------------------------------------------------------------ -argon2-cffi>=18.1.0 # https://github.com/hynek/argon2_cffi -awesome-slugify>=1.6.5 # https://github.com/dimka665/awesome-slugify -Pillow>=5.2.0 # https://github.com/python-pillow/Pillow -psycopg2>=2.7.5 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 -pytz>=2018.5 # https://github.com/stub42/pytz -redis>=2.10.5 # https://github.com/antirez/redis -whitenoise>=3.3.1 # https://github.com/evansd/whitenoise +argon2-cffi # https://github.com/hynek/argon2_cffi +awesome-slugify # https://github.com/dimka665/awesome-slugify +Pillow # https://github.com/python-pillow/Pillow +pytz # https://github.com/stub42/pytz +redis # https://github.com/antirez/redis +whitenoise # https://github.com/evansd/whitenoise # Django # ------------------------------------------------------------------------------ -django>=2.0.7 # pyup: < 2.1 # https://www.djangoproject.com/ -django-allauth>=0.35.0 # https://github.com/pennersr/django-allauth -django-contrib-comments>=1.8.0 # https://github.com/django/django-contrib-comments -django-crispy-forms>=1.7.2 # https://github.com/django-crispy-forms/django-crispy-forms -django-environ>=0.4.4 # https://github.com/joke2k/django-environ -django-markdownx>=2.0.23 # https://github.com/neutronX/django-markdownx -django-redis>=4.9.0 # https://github.com/niwinz/django-redis -django-taggit>=0.22.2 # https://github.com/alex/django-taggit -sorl-thumbnail>=12.4.1 # https://github.com/jazzband/sorl-thumbnail +django # pyup: < 2.1 # https://www.djangoproject.com/ +django-allauth # https://github.com/pennersr/django-allauth +django-contrib-comments # https://github.com/django/django-contrib-comments +django-crispy-forms # https://github.com/django-crispy-forms/django-crispy-forms +django-environ # https://github.com/joke2k/django-environ +django-markdownx # https://github.com/neutronX/django-markdownx +django-redis # https://github.com/niwinz/django-redis +django-taggit # https://github.com/alex/django-taggit +sorl-thumbnail # https://github.com/jazzband/sorl-thumbnail # Channels # ------------------------------------------------------------------------------ -channels>=2.1.1 # https://github.com/django/channels -channels-redis>=2.2.1 # https://github.com/django/channels_redis +channels # https://github.com/django/channels +channels-redis # https://github.com/django/channels_redis # GraphQL API # ------------------------------------------------------------------------------ -graphene-django>=2.0 # https://github.com/graphql-python/graphene-django +graphene-django # https://github.com/graphql-python/graphene-django diff --git a/requirements/local.txt b/requirements/local.txt index e5b864716..dbcd40782 100755 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -1,27 +1,26 @@ -r ./base.txt -ipdb>=0.11 # https://github.com/gotcha/ipdb -Sphinx>=1.7.1 # https://github.com/sphinx-doc/sphinx -Werkzeug>=0.14.1 # https://github.com/pallets/werkzeug +ipdb # https://github.com/gotcha/ipdb +Werkzeug # https://github.com/pallets/werkzeug +psycopg2-binary # https://github.com/psycopg/psycopg2 # Testing # ------------------------------------------------------------------------------ -pytest>=3.4.2 # https://github.com/pytest-dev/pytest -pytest-cov>=2.5.1 # https://github.com/pytest-dev/pytest-cov -pytest-sugar>=0.9.1 # https://github.com/Frozenball/pytest-sugar +pytest # https://github.com/pytest-dev/pytest +pytest-cov # https://github.com/pytest-dev/pytest-cov +pytest-sugar # https://github.com/Frozenball/pytest-sugar +pytest-django # https://github.com/pytest-dev/pytest-django # Code quality # ------------------------------------------------------------------------------ -coverage>=4.5.1 # https://github.com/nedbat/coveragepy -flake8>=3.5.0 # https://github.com/PyCQA/flake8 -pylint-common>=0.2.5 # https://github.com/landscapeio/pylint-common -pylint-django>=0.9.0 # https://github.com/PyCQA/pylint-django -pylint>=1.8 # https://github.com/PyCQA/pylint +coverage # https://github.com/nedbat/coveragepy +pylint-common # https://github.com/landscapeio/pylint-common +pylint-django # https://github.com/PyCQA/pylint-django +pylint # https://github.com/PyCQA/pylint # Django # ------------------------------------------------------------------------------ -django-coverage-plugin>=1.5.0 # https://github.com/nedbat/django_coverage_plugin -django-debug-toolbar>=1.9.1 # https://github.com/jazzband/django-debug-toolbar -django-extensions>=2.0.5 # https://github.com/django-extensions/django-extensions -django-test-plus>=1.0.22 # https://github.com/revsys/django-test-plus -pytest-django>=3.1.2 # https://github.com/pytest-dev/pytest-django +django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin +django-debug-toolbar # https://github.com/jazzband/django-debug-toolbar +django-extensions # https://github.com/django-extensions/django-extensions +django-test-plus # https://github.com/revsys/django-test-plus diff --git a/requirements/production.txt b/requirements/production.txt index 53acf8c37..174bef27b 100755 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -2,11 +2,12 @@ -r base.txt -boto3>=1.6.2 # pyup: update minor # https://github.com/boto/boto3 -gevent>=1.2.2 # https://github.com/gevent/gevent -raven>=6.6.0 # https://github.com/getsentry/raven-python +boto3 # pyup: update minor # https://github.com/boto/boto3 +gevent # https://github.com/gevent/gevent +raven # https://github.com/getsentry/raven-python +psycopg2 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 # Django # ------------------------------------------------------------------------------ -django-anymail>=2.0 # https://github.com/anymail/django-anymail -django-storages>=1.6.5 # https://github.com/jschneier/django-storages +django-anymail # https://github.com/anymail/django-anymail +django-storages # https://github.com/jschneier/django-storages From bba495a1dd0ae026bab5d1a4d44d9db9c3ecd9b2 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 1 Sep 2019 09:54:33 -0500 Subject: [PATCH 11/50] Adding bandit, how could I forgot. --- requirements/local.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/local.txt b/requirements/local.txt index dbcd40782..2d0ef0b5a 100755 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -14,6 +14,7 @@ pytest-django # https://github.com/pytest-dev/pytest-django # Code quality # ------------------------------------------------------------------------------ coverage # https://github.com/nedbat/coveragepy +bandit # https://github.com/PyCQA/bandit pylint-common # https://github.com/landscapeio/pylint-common pylint-django # https://github.com/PyCQA/pylint-django pylint # https://github.com/PyCQA/pylint From c330fa97c73f0d25b79730635d7e83a8b169f6a5 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 1 Sep 2019 10:29:53 -0500 Subject: [PATCH 12/50] Updating PostgreSQL. --- compose/production/postgres/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/production/postgres/Dockerfile b/compose/production/postgres/Dockerfile index 4a848ee14..b80cdbe5a 100755 --- a/compose/production/postgres/Dockerfile +++ b/compose/production/postgres/Dockerfile @@ -1,4 +1,4 @@ -FROM postgres:10 +FROM postgres:11-alpine COPY ./compose/production/postgres/backup.sh /usr/local/bin/backup RUN chmod +x /usr/local/bin/backup From f433ee3b30bc65aba4d424fedd437fba5240b393 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 1 Sep 2019 22:29:34 -0500 Subject: [PATCH 13/50] Missing change. --- dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev.yml b/dev.yml index c08c8306f..4d00f8197 100755 --- a/dev.yml +++ b/dev.yml @@ -6,7 +6,7 @@ volumes: services: postgres: - image: postgres:10 + image: postgres:11-alpine volumes: - postgres_data:/var/lib/postgresql/data env_file: .env From 5bb6b8a28b6281cd4eb645c7d578fac4f1e87b80 Mon Sep 17 00:00:00 2001 From: Matej Bobrik Date: Sun, 3 Nov 2019 19:39:12 +0100 Subject: [PATCH 14/50] Modified .js logic for QA votes - fix #183 --- bootcamp/static/js/qa.js | 22 ++++++++++++++-------- bootcamp/templates/qa/answer_sample.html | 4 ++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/bootcamp/static/js/qa.js b/bootcamp/static/js/qa.js index 2427fb0ad..6b5ca7493 100755 --- a/bootcamp/static/js/qa.js +++ b/bootcamp/static/js/qa.js @@ -65,10 +65,13 @@ $(function () { type: 'post', cache: false, success: function (data) { - $('.vote', span).removeClass('voted'); - if (vote === "U") { - $(span).addClass('voted'); - } + if (vote === "U") { + $('#questionUpVote').addClass('voted'); + $('#questionDownVote').removeClass('voted'); + } else { + $('#questionDownVote').addClass('voted'); + $('#questionUpVote').removeClass('voted'); + } $("#questionVotes").text(data.votes); } }); @@ -93,10 +96,13 @@ $(function () { type: 'post', cache: false, success: function (data) { - $('.vote', span).removeClass('voted'); - if (vote === "U") { - $(span).addClass('voted'); - } + if (vote === "U") { + $('#answerUpVote').addClass('voted'); + $('#answerDownVote').removeClass('voted'); + } else { + $('#answerDownVote').addClass('voted'); + $('#answerUpVote').removeClass('voted'); + } $("#answerVotes").text(data.votes); } }); diff --git a/bootcamp/templates/qa/answer_sample.html b/bootcamp/templates/qa/answer_sample.html index eda25b178..d4450779e 100755 --- a/bootcamp/templates/qa/answer_sample.html +++ b/bootcamp/templates/qa/answer_sample.html @@ -3,9 +3,9 @@
    {% csrf_token %}
    - + {{ answer.total_votes }} - + {% if answer.is_answer and user == question.user %} {% elif answer.is_answer %} From 409a1157d095b6d3bac0361ce246eac301059813 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Mon, 4 Nov 2019 11:47:12 -0500 Subject: [PATCH 15/50] Quick Blackening to the code base. --- bootcamp/__init__.py | 9 +- bootcamp/articles/admin.py | 4 +- bootcamp/articles/apps.py | 4 +- bootcamp/articles/forms.py | 3 +- bootcamp/articles/migrations/0001_initial.py | 69 ++++-- bootcamp/articles/models.py | 35 +-- bootcamp/articles/tests/test_views.py | 58 +++-- bootcamp/articles/urls.py | 23 +- bootcamp/articles/views.py | 15 +- .../contrib/sites/migrations/0001_initial.py | 39 +-- .../migrations/0002_alter_domain_unique.py | 16 +- .../0003_set_site_domain_and_name.py | 24 +- bootcamp/helpers.py | 2 + bootcamp/messager/apps.py | 4 +- bootcamp/messager/consumers.py | 9 +- bootcamp/messager/migrations/0001_initial.py | 53 ++-- bootcamp/messager/models.py | 41 ++-- bootcamp/messager/schema.py | 3 +- bootcamp/messager/tests/test_models.py | 20 +- bootcamp/messager/tests/test_views.py | 75 +++--- bootcamp/messager/urls.py | 16 +- bootcamp/messager/views.py | 33 +-- bootcamp/news/admin.py | 4 +- bootcamp/news/apps.py | 2 +- bootcamp/news/migrations/0001_initial.py | 63 +++-- bootcamp/news/tests/test_models.py | 8 +- bootcamp/news/tests/test_views.py | 48 ++-- bootcamp/news/urls.py | 21 +- bootcamp/news/views.py | 42 ++-- bootcamp/notifications/admin.py | 4 +- bootcamp/notifications/apps.py | 4 +- bootcamp/notifications/consumers.py | 7 +- .../notifications/migrations/0001_initial.py | 84 +++++-- bootcamp/notifications/models.py | 168 +++++++------ bootcamp/notifications/tests/test_models.py | 23 +- bootcamp/notifications/tests/test_views.py | 26 +- bootcamp/notifications/urls.py | 14 +- bootcamp/notifications/views.py | 31 ++- bootcamp/qa/apps.py | 4 +- bootcamp/qa/migrations/0001_initial.py | 151 ++++++++---- bootcamp/qa/models.py | 42 ++-- bootcamp/qa/tests/test_models.py | 39 +-- bootcamp/qa/tests/test_views.py | 43 ++-- bootcamp/qa/urls.py | 28 ++- bootcamp/qa/views.py | 31 +-- bootcamp/search/apps.py | 4 +- bootcamp/search/tests/test_views.py | 53 ++-- bootcamp/search/urls.py | 6 +- bootcamp/search/views.py | 86 ++++--- bootcamp/users/adapters.py | 4 +- bootcamp/users/admin.py | 16 +- bootcamp/users/apps.py | 2 +- bootcamp/users/migrations/0001_initial.py | 230 +++++++++++++++--- bootcamp/users/models.py | 32 +-- bootcamp/users/schema.py | 3 +- bootcamp/users/tests/test_admin.py | 31 +-- bootcamp/users/tests/test_models.py | 8 +- bootcamp/users/tests/test_urls.py | 23 +- bootcamp/users/tests/test_views.py | 27 +- bootcamp/users/urls.py | 24 +- bootcamp/users/views.py | 31 ++- config/asgi.py | 2 +- config/routing.py | 25 +- config/settings/base.py | 218 ++++++++--------- config/settings/local.py | 46 ++-- config/settings/production.py | 173 ++++++------- config/settings/test.py | 30 ++- config/urls.py | 66 +++-- config/wsgi.py | 11 +- manage.py | 6 +- 70 files changed, 1534 insertions(+), 1065 deletions(-) diff --git a/bootcamp/__init__.py b/bootcamp/__init__.py index dc0c5c188..055908fac 100755 --- a/bootcamp/__init__.py +++ b/bootcamp/__init__.py @@ -1,2 +1,7 @@ -__version__ = '2.0.0' -__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) +__version__ = "2.0.0" +__version_info__ = tuple( + [ + int(num) if num.isdigit() else num + for num in __version__.replace("-", ".", 1).split(".") + ] +) diff --git a/bootcamp/articles/admin.py b/bootcamp/articles/admin.py index b91078594..d9023af88 100755 --- a/bootcamp/articles/admin.py +++ b/bootcamp/articles/admin.py @@ -4,5 +4,5 @@ @admin.register(Article) class ArticleAdmin(admin.ModelAdmin): - list_display = ('title', 'user', 'status') - list_filter = ('user', 'status', 'timestamp') + list_display = ("title", "user", "status") + list_filter = ("user", "status", "timestamp") diff --git a/bootcamp/articles/apps.py b/bootcamp/articles/apps.py index 037a11955..479d6eb68 100755 --- a/bootcamp/articles/apps.py +++ b/bootcamp/articles/apps.py @@ -3,5 +3,5 @@ class ArticlesConfig(AppConfig): - name = 'bootcamp.articles' - verbose_name = _('Articles') + name = "bootcamp.articles" + verbose_name = _("Articles") diff --git a/bootcamp/articles/forms.py b/bootcamp/articles/forms.py index 8cf1e6a27..cc455be46 100755 --- a/bootcamp/articles/forms.py +++ b/bootcamp/articles/forms.py @@ -8,7 +8,8 @@ class ArticleForm(forms.ModelForm): status = forms.CharField(widget=forms.HiddenInput()) edited = forms.BooleanField( - widget=forms.HiddenInput(), required=False, initial=False) + widget=forms.HiddenInput(), required=False, initial=False + ) content = MarkdownxFormField() class Meta: diff --git a/bootcamp/articles/migrations/0001_initial.py b/bootcamp/articles/migrations/0001_initial.py index 8345dd551..bb47a8e98 100755 --- a/bootcamp/articles/migrations/0001_initial.py +++ b/bootcamp/articles/migrations/0001_initial.py @@ -12,29 +12,66 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('taggit', '0002_auto_20150616_2121'), + ("taggit", "0002_auto_20150616_2121"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Article', + name="Article", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('image', models.ImageField(upload_to='articles_pictures/%Y/%m/%d/', verbose_name='Featured image')), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('title', models.CharField(max_length=255, unique=True)), - ('slug', models.SlugField(blank=True, max_length=80, null=True)), - ('status', models.CharField(choices=[('D', 'Draft'), ('P', 'Published')], default='D', max_length=1)), - ('content', markdownx.models.MarkdownxField()), - ('edited', models.BooleanField(default=False)), - ('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='author', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "image", + models.ImageField( + upload_to="articles_pictures/%Y/%m/%d/", + verbose_name="Featured image", + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("title", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField(blank=True, max_length=80, null=True)), + ( + "status", + models.CharField( + choices=[("D", "Draft"), ("P", "Published")], + default="D", + max_length=1, + ), + ), + ("content", markdownx.models.MarkdownxField()), + ("edited", models.BooleanField(default=False)), + ( + "tags", + taggit.managers.TaggableManager( + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="author", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Article', - 'verbose_name_plural': 'Articles', - 'ordering': ('-timestamp',), + "verbose_name": "Article", + "verbose_name_plural": "Articles", + "ordering": ("-timestamp",), }, - ), + ) ] diff --git a/bootcamp/articles/models.py b/bootcamp/articles/models.py index 71ff509ce..a88c1885f 100755 --- a/bootcamp/articles/models.py +++ b/bootcamp/articles/models.py @@ -27,8 +27,9 @@ def get_drafts(self): def get_counted_tags(self): tag_dict = {} - query = self.filter(status='P').annotate( - tagged=Count('tags')).filter(tags__gt=0) + query = ( + self.filter(status="P").annotate(tagged=Count("tags")).filter(tags__gt=0) + ) for obj in query: for tag in obj.tags.names(): if tag not in tag_dict: @@ -43,16 +44,17 @@ def get_counted_tags(self): class Article(models.Model): DRAFT = "D" PUBLISHED = "P" - STATUS = ( - (DRAFT, _("Draft")), - (PUBLISHED, _("Published")), - ) + STATUS = ((DRAFT, _("Draft")), (PUBLISHED, _("Published"))) user = models.ForeignKey( - settings.AUTH_USER_MODEL, null=True, related_name="author", - on_delete=models.SET_NULL) + settings.AUTH_USER_MODEL, + null=True, + related_name="author", + on_delete=models.SET_NULL, + ) image = models.ImageField( - _('Featured image'), upload_to='articles_pictures/%Y/%m/%d/') + _("Featured image"), upload_to="articles_pictures/%Y/%m/%d/" + ) timestamp = models.DateTimeField(auto_now_add=True) title = models.CharField(max_length=255, null=False, unique=True) slug = models.SlugField(max_length=80, null=True, blank=True) @@ -72,8 +74,9 @@ def __str__(self): def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(f"{self.user.username}-{self.title}", - to_lower=True, max_length=80) + self.slug = slugify( + f"{self.user.username}-{self.title}", to_lower=True, max_length=80 + ) super().save(*args, **kwargs) @@ -84,12 +87,10 @@ def get_markdown(self): def notify_comment(**kwargs): """Handler to be fired up upon comments signal to notify the author of a given article.""" - actor = kwargs['request'].user - receiver = kwargs['comment'].content_object.user - obj = kwargs['comment'].content_object - notification_handler( - actor, receiver, Notification.COMMENTED, action_object=obj - ) + actor = kwargs["request"].user + receiver = kwargs["comment"].content_object.user + obj = kwargs["comment"].content_object + notification_handler(actor, receiver, Notification.COMMENTED, action_object=obj) comment_was_posted.connect(receiver=notify_comment) diff --git a/bootcamp/articles/tests/test_views.py b/bootcamp/articles/tests/test_views.py index 512684ddd..464110a1f 100755 --- a/bootcamp/articles/tests/test_views.py +++ b/bootcamp/articles/tests/test_views.py @@ -53,29 +53,38 @@ def test_index_articles(self): self.assertEqual(response.status_code, 200) def test_error_404(self): - response_no_art = self.client.get(reverse( - "articles:article", kwargs={"slug": "no-slug"})) + response_no_art = self.client.get( + reverse("articles:article", kwargs={"slug": "no-slug"}) + ) self.assertEqual(response_no_art.status_code, 404) @override_settings(MEDIA_ROOT=tempfile.gettempdir()) def test_create_article(self): - response = self.client.post(reverse("articles:write_new"), - {"title": "A not that really nice title", - "content": "Whatever works for you", - "tags": "list, lists", - "status": "P", - "image": self.test_image}) + response = self.client.post( + reverse("articles:write_new"), + { + "title": "A not that really nice title", + "content": "Whatever works for you", + "tags": "list, lists", + "status": "P", + "image": self.test_image, + }, + ) assert response.status_code == 302 @override_settings(MEDIA_ROOT=tempfile.gettempdir()) def test_single_article(self): current_count = Article.objects.count() - response = self.client.post(reverse("articles:write_new"), - {"title": "A not that really nice title", - "content": "Whatever works for you", - "tags": "list, lists", - "status": "P", - "image": self.test_image}) + response = self.client.post( + reverse("articles:write_new"), + { + "title": "A not that really nice title", + "content": "Whatever works for you", + "tags": "list, lists", + "status": "P", + "image": self.test_image, + }, + ) # response_art = self.client.get( # reverse("articles:article", # kwargs={"slug": "a-not-that-really-nice-title"})) @@ -85,13 +94,20 @@ def test_single_article(self): @override_settings(MEDIA_ROOT=tempfile.gettempdir()) def test_draft_article(self): - response = self.client.post(reverse("articles:write_new"), - {"title": "A not that really nice title", - "content": "Whatever works for you", - "tags": "list, lists", - "status": "D", - "image": self.test_image}) + response = self.client.post( + reverse("articles:write_new"), + { + "title": "A not that really nice title", + "content": "Whatever works for you", + "tags": "list, lists", + "status": "D", + "image": self.test_image, + }, + ) resp = self.client.get(reverse("articles:drafts")) assert resp.status_code == 200 assert response.status_code == 302 - assert resp.context["articles"][0].slug == "first-user-a-not-that-really-nice-title" + assert ( + resp.context["articles"][0].slug + == "first-user-a-not-that-really-nice-title" + ) diff --git a/bootcamp/articles/urls.py b/bootcamp/articles/urls.py index b73d3410b..74a286506 100755 --- a/bootcamp/articles/urls.py +++ b/bootcamp/articles/urls.py @@ -1,13 +1,18 @@ from django.conf.urls import url -from bootcamp.articles.views import (ArticlesListView, DraftsListView, - CreateArticleView, EditArticleView, - DetailArticleView) -app_name = 'articles' +from bootcamp.articles.views import ( + ArticlesListView, + DraftsListView, + CreateArticleView, + EditArticleView, + DetailArticleView, +) + +app_name = "articles" urlpatterns = [ - url(r'^$', ArticlesListView.as_view(), name='list'), - url(r'^write-new-article/$', CreateArticleView.as_view(), name='write_new'), - url(r'^drafts/$', DraftsListView.as_view(), name='drafts'), - url(r'^edit/(?P\d+)/$', EditArticleView.as_view(), name='edit_article'), - url(r'^(?P[-\w]+)/$', DetailArticleView.as_view(), name='article'), + url(r"^$", ArticlesListView.as_view(), name="list"), + url(r"^write-new-article/$", CreateArticleView.as_view(), name="write_new"), + url(r"^drafts/$", DraftsListView.as_view(), name="drafts"), + url(r"^edit/(?P\d+)/$", EditArticleView.as_view(), name="edit_article"), + url(r"^(?P[-\w]+)/$", DetailArticleView.as_view(), name="article"), ] diff --git a/bootcamp/articles/views.py b/bootcamp/articles/views.py index b6ac4e54c..c7013c717 100755 --- a/bootcamp/articles/views.py +++ b/bootcamp/articles/views.py @@ -11,13 +11,14 @@ class ArticlesListView(LoginRequiredMixin, ListView): """Basic ListView implementation to call the published articles list.""" + model = Article paginate_by = 15 context_object_name = "articles" def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['popular_tags'] = Article.objects.get_counted_tags() + context["popular_tags"] = Article.objects.get_counted_tags() return context def get_queryset(self, **kwargs): @@ -27,16 +28,18 @@ def get_queryset(self, **kwargs): class DraftsListView(ArticlesListView): """Overriding the original implementation to call the drafts articles list.""" + def get_queryset(self, **kwargs): return Article.objects.get_drafts() class CreateArticleView(LoginRequiredMixin, CreateView): """Basic CreateView implementation to create new articles.""" + model = Article message = _("Your article has been created.") form_class = ArticleForm - template_name = 'articles/article_create.html' + template_name = "articles/article_create.html" def form_valid(self, form): form.instance.user = self.request.user @@ -44,15 +47,16 @@ def form_valid(self, form): def get_success_url(self): messages.success(self.request, self.message) - return reverse('articles:list') + return reverse("articles:list") class EditArticleView(LoginRequiredMixin, AuthorRequiredMixin, UpdateView): """Basic EditView implementation to edit existing articles.""" + model = Article message = _("Your article has been updated.") form_class = ArticleForm - template_name = 'articles/article_update.html' + template_name = "articles/article_update.html" def form_valid(self, form): form.instance.user = self.request.user @@ -60,9 +64,10 @@ def form_valid(self, form): def get_success_url(self): messages.success(self.request, self.message) - return reverse('articles:list') + return reverse("articles:list") class DetailArticleView(LoginRequiredMixin, DetailView): """Basic DetailView implementation to call an individual article.""" + model = Article diff --git a/bootcamp/contrib/sites/migrations/0001_initial.py b/bootcamp/contrib/sites/migrations/0001_initial.py index a7639869f..304cd6d7c 100755 --- a/bootcamp/contrib/sites/migrations/0001_initial.py +++ b/bootcamp/contrib/sites/migrations/0001_initial.py @@ -9,23 +9,34 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Site', + name="Site", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('domain', models.CharField( - max_length=100, verbose_name='domain name', validators=[_simple_domain_name_validator] - )), - ('name', models.CharField(max_length=50, verbose_name='display name')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ( + "domain", + models.CharField( + max_length=100, + verbose_name="domain name", + validators=[_simple_domain_name_validator], + ), + ), + ("name", models.CharField(max_length=50, verbose_name="display name")), ], options={ - 'ordering': ('domain',), - 'db_table': 'django_site', - 'verbose_name': 'site', - 'verbose_name_plural': 'sites', + "ordering": ("domain",), + "db_table": "django_site", + "verbose_name": "site", + "verbose_name_plural": "sites", }, bases=(models.Model,), - managers=[ - ('objects', django.contrib.sites.models.SiteManager()), - ], - ), + managers=[("objects", django.contrib.sites.models.SiteManager())], + ) ] diff --git a/bootcamp/contrib/sites/migrations/0002_alter_domain_unique.py b/bootcamp/contrib/sites/migrations/0002_alter_domain_unique.py index 6a26ebcde..2c8d6dac0 100755 --- a/bootcamp/contrib/sites/migrations/0002_alter_domain_unique.py +++ b/bootcamp/contrib/sites/migrations/0002_alter_domain_unique.py @@ -4,17 +4,17 @@ class Migration(migrations.Migration): - dependencies = [ - ('sites', '0001_initial'), - ] + dependencies = [("sites", "0001_initial")] operations = [ migrations.AlterField( - model_name='site', - name='domain', + model_name="site", + name="domain", field=models.CharField( - max_length=100, unique=True, validators=[django.contrib.sites.models._simple_domain_name_validator], - verbose_name='domain name' + max_length=100, + unique=True, + validators=[django.contrib.sites.models._simple_domain_name_validator], + verbose_name="domain name", ), - ), + ) ] diff --git a/bootcamp/contrib/sites/migrations/0003_set_site_domain_and_name.py b/bootcamp/contrib/sites/migrations/0003_set_site_domain_and_name.py index c93f8e1d9..4291e1d5d 100755 --- a/bootcamp/contrib/sites/migrations/0003_set_site_domain_and_name.py +++ b/bootcamp/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -9,34 +9,26 @@ def update_site_forward(apps, schema_editor): """Set site domain and name.""" - Site = apps.get_model('sites', 'Site') + Site = apps.get_model("sites", "Site") Site.objects.update_or_create( id=settings.SITE_ID, defaults={ - 'domain': 'vitor@freitas.com trybootcamp.vitorfs.com', - 'name': 'Bootcamp' - } + "domain": "vitor@freitas.com trybootcamp.vitorfs.com", + "name": "Bootcamp", + }, ) def update_site_backward(apps, schema_editor): """Revert site domain and name to default.""" - Site = apps.get_model('sites', 'Site') + Site = apps.get_model("sites", "Site") Site.objects.update_or_create( - id=settings.SITE_ID, - defaults={ - 'domain': 'example.com', - 'name': 'example.com' - } + id=settings.SITE_ID, defaults={"domain": "example.com", "name": "example.com"} ) class Migration(migrations.Migration): - dependencies = [ - ('sites', '0002_alter_domain_unique'), - ] + dependencies = [("sites", "0002_alter_domain_unique")] - operations = [ - migrations.RunPython(update_site_forward, update_site_backward), - ] + operations = [migrations.RunPython(update_site_forward, update_site_backward)] diff --git a/bootcamp/helpers.py b/bootcamp/helpers.py index 34858da68..098bd4d3a 100755 --- a/bootcamp/helpers.py +++ b/bootcamp/helpers.py @@ -29,6 +29,7 @@ def paginate_data(qs, page_size, page, paginated_type, **kwargs): def ajax_required(f): """Not a mixin, but a nice decorator to validate than a request is AJAX""" + def wrap(request, *args, **kwargs): if not request.is_ajax(): return HttpResponseBadRequest() @@ -43,6 +44,7 @@ def wrap(request, *args, **kwargs): class AuthorRequiredMixin(View): """Mixin to validate than the loggedin user is the creator of the object to be edited or updated.""" + def dispatch(self, request, *args, **kwargs): obj = self.get_object() if obj.user != self.request.user: diff --git a/bootcamp/messager/apps.py b/bootcamp/messager/apps.py index 493d73a62..c0885accb 100755 --- a/bootcamp/messager/apps.py +++ b/bootcamp/messager/apps.py @@ -3,5 +3,5 @@ class MessagerConfig(AppConfig): - name = 'bootcamp.messager' - verbose_name = _('Messager') + name = "bootcamp.messager" + verbose_name = _("Messager") diff --git a/bootcamp/messager/consumers.py b/bootcamp/messager/consumers.py index 457d126d4..e713a9da9 100755 --- a/bootcamp/messager/consumers.py +++ b/bootcamp/messager/consumers.py @@ -6,6 +6,7 @@ class MessagerConsumer(AsyncWebsocketConsumer): """Consumer to manage WebSocket connections for the Messager app. """ + async def connect(self): """Consumer Connect implementation, to validate user status and prevent non authenticated user to take advante from the connection.""" @@ -15,13 +16,17 @@ async def connect(self): else: # Accept the connection - await self.channel_layer.group_add(f"{self.scope['user'].username}", self.channel_name) + await self.channel_layer.group_add( + f"{self.scope['user'].username}", self.channel_name + ) await self.accept() async def disconnect(self, close_code): """Consumer implementation to leave behind the group at the moment the closes the connection.""" - await self.channel_layer.group_discard(f"{self.scope['user'].username}", self.channel_name) + await self.channel_layer.group_discard( + f"{self.scope['user'].username}", self.channel_name + ) async def receive(self, text_data): """Receive method implementation to redirect any new message received diff --git a/bootcamp/messager/migrations/0001_initial.py b/bootcamp/messager/migrations/0001_initial.py index 380bb6382..74a7e1651 100755 --- a/bootcamp/messager/migrations/0001_initial.py +++ b/bootcamp/messager/migrations/0001_initial.py @@ -10,25 +10,50 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( - name='Message', + name="Message", fields=[ - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('message', models.TextField(blank=True, max_length=1000)), - ('unread', models.BooleanField(db_index=True, default=True)), - ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), - ('sender', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("message", models.TextField(blank=True, max_length=1000)), + ("unread", models.BooleanField(db_index=True, default=True)), + ( + "recipient", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="received_messages", + to=settings.AUTH_USER_MODEL, + verbose_name="Recipient", + ), + ), + ( + "sender", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sent_messages", + to=settings.AUTH_USER_MODEL, + verbose_name="Sender", + ), + ), ], options={ - 'verbose_name': 'Message', - 'verbose_name_plural': 'Messages', - 'ordering': ('-timestamp',), + "verbose_name": "Message", + "verbose_name_plural": "Messages", + "ordering": ("-timestamp",), }, - ), + ) ] diff --git a/bootcamp/messager/models.py b/bootcamp/messager/models.py index 654a18ec1..65263ab0b 100755 --- a/bootcamp/messager/models.py +++ b/bootcamp/messager/models.py @@ -17,7 +17,7 @@ def get_conversation(self, sender, recipient): """Returns all the messages sent between two users.""" qs_one = self.filter(sender=sender, recipient=recipient) qs_two = self.filter(sender=recipient, recipient=sender) - return qs_one.union(qs_two).order_by('timestamp') + return qs_one.union(qs_two).order_by("timestamp") def get_most_recent_conversation(self, recipient): """Returns the most recent conversation counterpart's username.""" @@ -41,14 +41,23 @@ def mark_conversation_as_read(self, sender, recipient): class Message(models.Model): """A private message sent between users.""" - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) + + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) sender = models.ForeignKey( - settings.AUTH_USER_MODEL, related_name='sent_messages', - verbose_name=_("Sender"), null=True, on_delete=models.SET_NULL) + settings.AUTH_USER_MODEL, + related_name="sent_messages", + verbose_name=_("Sender"), + null=True, + on_delete=models.SET_NULL, + ) recipient = models.ForeignKey( - settings.AUTH_USER_MODEL, related_name='received_messages', null=True, - blank=True, verbose_name=_("Recipient"), on_delete=models.SET_NULL) + settings.AUTH_USER_MODEL, + related_name="received_messages", + null=True, + blank=True, + verbose_name=_("Recipient"), + on_delete=models.SET_NULL, + ) timestamp = models.DateTimeField(auto_now_add=True) message = models.TextField(max_length=1000, blank=True) unread = models.BooleanField(default=True, db_index=True) @@ -57,7 +66,7 @@ class Message(models.Model): class Meta: verbose_name = _("Message") verbose_name_plural = _("Messages") - ordering = ("-timestamp", ) + ordering = ("-timestamp",) def __str__(self): return self.message @@ -79,17 +88,15 @@ def send_message(sender, recipient, message): actual message. """ new_message = Message.objects.create( - sender=sender, - recipient=recipient, - message=message + sender=sender, recipient=recipient, message=message ) channel_layer = get_channel_layer() payload = { - 'type': 'receive', - 'key': 'message', - 'message_id': new_message.uuid_id, - 'sender': sender, - 'recipient': recipient - } + "type": "receive", + "key": "message", + "message_id": new_message.uuid_id, + "sender": sender, + "recipient": recipient, + } async_to_sync(channel_layer.group_send)(recipient.username, payload) return new_message diff --git a/bootcamp/messager/schema.py b/bootcamp/messager/schema.py index 92bdd3e02..ca4b14d04 100755 --- a/bootcamp/messager/schema.py +++ b/bootcamp/messager/schema.py @@ -15,6 +15,7 @@ class Meta: class MessageQuery(object): """Abstract object to register in the root schema, allowing to query the model.""" + conversation = graphene.List(MessageType) def resolve_conversation(self, info, **kwargs): @@ -24,7 +25,7 @@ def resolve_conversation(self, info, **kwargs): return Message.objects.get_conversation(sender, recipient) def resolve_message(self, info, **kwargs): - uuid_id = kwargs.get('uuid_id') + uuid_id = kwargs.get("uuid_id") if uuid_id is not None: return Message.objects.get(uuid_id=uuid_id) diff --git a/bootcamp/messager/tests/test_models.py b/bootcamp/messager/tests/test_models.py index 196222af8..a081b7d0d 100755 --- a/bootcamp/messager/tests/test_models.py +++ b/bootcamp/messager/tests/test_models.py @@ -10,17 +10,13 @@ def setUp(self): self.first_message = Message.objects.create( sender=self.user, recipient=self.other_user, - message="A not that long message." + message="A not that long message.", ) self.second_message = Message.objects.create( - sender=self.user, - recipient=self.other_user, - message="A follow up message." + sender=self.user, recipient=self.other_user, message="A follow up message." ) self.third_message = Message.objects.create( - sender=self.other_user, - recipient=self.user, - message="An answer message." + sender=self.other_user, recipient=self.user, message="An answer message." ) def test_object_instance(self): @@ -31,13 +27,11 @@ def test_return_values(self): assert self.first_message.message == "A not that long message." def test_conversation(self): - conversation = Message.objects.get_conversation( - self.user, self.other_user) + conversation = Message.objects.get_conversation(self.user, self.other_user) assert conversation.last().message == "An answer message." def test_recent_conversation(self): - active_user = Message.objects.get_most_recent_conversation( - self.user) + active_user = Message.objects.get_most_recent_conversation(self.user) assert active_user == self.other_user def test_single_marking_as_read(self): @@ -47,7 +41,5 @@ def test_single_marking_as_read(self): def test_sending_new_message(self): initial_count = Message.objects.count() - Message.send_message( - self.other_user, self.user, "A follow up answer message." - ) + Message.send_message(self.other_user, self.user, "A follow up answer message.") assert Message.objects.count() == initial_count + 1 diff --git a/bootcamp/messager/tests/test_views.py b/bootcamp/messager/tests/test_views.py index b78197c24..609ceb399 100755 --- a/bootcamp/messager/tests/test_views.py +++ b/bootcamp/messager/tests/test_views.py @@ -17,17 +17,13 @@ def setUp(self): self.first_message = Message.objects.create( sender=self.user, recipient=self.other_user, - message="A not that long message." + message="A not that long message.", ) self.second_message = Message.objects.create( - sender=self.user, - recipient=self.other_user, - message="A follow up message." + sender=self.user, recipient=self.other_user, message="A follow up message." ) self.third_message = Message.objects.create( - sender=self.other_user, - recipient=self.user, - message="An answer message." + sender=self.other_user, recipient=self.user, message="An answer message." ) def test_user_messages(self): @@ -37,50 +33,61 @@ def test_user_messages(self): def test_user_conversation(self): response = self.client.get( - reverse("messager:conversation_detail", - kwargs={"username": self.user.username})) + reverse( + "messager:conversation_detail", kwargs={"username": self.user.username} + ) + ) assert response.status_code == 200 assert str(response.context["active"]) == "first_user" def test_send_message_view(self): message_count = Message.objects.count() - request = self.client.post(reverse("messager:send_message"), - {"to": "second_user", - "message": "A new short message"}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + request = self.client.post( + reverse("messager:send_message"), + {"to": "second_user", "message": "A new short message"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert request.status_code == 200 new_msm_count = Message.objects.count() assert new_msm_count == message_count + 1 def test_wrong_requests_send_message(self): - get_request = self.client.get(reverse("messager:send_message"), - {"to": "second_user", - "message": "A new short message"}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") - no_ajax_request = self.client.get(reverse("messager:send_message"), - {"to": "second_user", - "message": "A new short message"}) - same_user_request = self.client.post(reverse("messager:send_message"), - {"to": "first_user", - "message": "A new short message"}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") - no_lenght_request = self.client.post(reverse("messager:send_message"), - {"to": "second_user", - "message": ""}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + get_request = self.client.get( + reverse("messager:send_message"), + {"to": "second_user", "message": "A new short message"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + no_ajax_request = self.client.get( + reverse("messager:send_message"), + {"to": "second_user", "message": "A new short message"}, + ) + same_user_request = self.client.post( + reverse("messager:send_message"), + {"to": "first_user", "message": "A new short message"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + no_lenght_request = self.client.post( + reverse("messager:send_message"), + {"to": "second_user", "message": ""}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert get_request.status_code == 405 assert no_ajax_request.status_code == 400 assert same_user_request.status_code == 200 assert no_lenght_request.status_code == 200 def test_message_reception_view(self): - request = self.client.get(reverse("messager:receive_message"), - {"message_id": self.third_message.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + request = self.client.get( + reverse("messager:receive_message"), + {"message_id": self.third_message.uuid_id}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert b"An answer message." in request.content def test_wrong_request_recieve_message_view(self): - request = self.client.post(reverse("messager:receive_message"), - {"message_id": self.third_message.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + request = self.client.post( + reverse("messager:receive_message"), + {"message_id": self.third_message.uuid_id}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert request.status_code == 405 diff --git a/bootcamp/messager/urls.py b/bootcamp/messager/urls.py index 3c76f6225..5dcb79605 100755 --- a/bootcamp/messager/urls.py +++ b/bootcamp/messager/urls.py @@ -2,12 +2,14 @@ from bootcamp.messager import views -app_name = 'messager' +app_name = "messager" urlpatterns = [ - url(r'^$', views.MessagesListView.as_view(), name='messages_list'), - url(r'^send-message/$', views.send_message, name='send_message'), - url(r'^receive-message/$', - views.receive_message, name='receive_message'), - url(r'^(?P[\w.@+-]+)/$', views.ConversationListView.as_view(), - name='conversation_detail'), + url(r"^$", views.MessagesListView.as_view(), name="messages_list"), + url(r"^send-message/$", views.send_message, name="send_message"), + url(r"^receive-message/$", views.receive_message, name="receive_message"), + url( + r"^(?P[\w.@+-]+)/$", + views.ConversationListView.as_view(), + name="conversation_detail", + ), ] diff --git a/bootcamp/messager/views.py b/bootcamp/messager/views.py index 7e64c7ffc..744776efe 100755 --- a/bootcamp/messager/views.py +++ b/bootcamp/messager/views.py @@ -14,38 +14,41 @@ class MessagesListView(LoginRequiredMixin, ListView): """CBV to render the inbox, showing by default the most recent conversation as the active one. """ + model = Message paginate_by = 50 template_name = "messager/message_list.html" def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['users_list'] = get_user_model().objects.filter( - is_active=True).exclude( - username=self.request.user).order_by('username') + context["users_list"] = ( + get_user_model() + .objects.filter(is_active=True) + .exclude(username=self.request.user) + .order_by("username") + ) last_conversation = Message.objects.get_most_recent_conversation( self.request.user ) - context['active'] = last_conversation.username + context["active"] = last_conversation.username return context def get_queryset(self): - active_user = Message.objects.get_most_recent_conversation( - self.request.user) + active_user = Message.objects.get_most_recent_conversation(self.request.user) return Message.objects.get_conversation(active_user, self.request.user) class ConversationListView(MessagesListView): """CBV to render the inbox, showing an specific conversation with a given user, who requires to be active too.""" + def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['active'] = self.kwargs["username"] + context["active"] = self.kwargs["username"] return context def get_queryset(self): - active_user = get_user_model().objects.get( - username=self.kwargs["username"]) + active_user = get_user_model().objects.get(username=self.kwargs["username"]) return Message.objects.get_conversation(active_user, self.request.user) @@ -57,16 +60,15 @@ def send_message(request): and create the new message and return the new data to be attached to the conversation stream.""" sender = request.user - recipient_username = request.POST.get('to') + recipient_username = request.POST.get("to") recipient = get_user_model().objects.get(username=recipient_username) - message = request.POST.get('message') + message = request.POST.get("message") if len(message.strip()) == 0: return HttpResponse() if sender != recipient: msg = Message.send_message(sender, recipient, message) - return render(request, 'messager/single_message.html', - {'message': msg}) + return render(request, "messager/single_message.html", {"message": msg}) return HttpResponse() @@ -77,7 +79,6 @@ def send_message(request): def receive_message(request): """Simple AJAX functional view to return a rendered single message on the receiver side providing realtime connections.""" - message_id = request.GET.get('message_id') + message_id = request.GET.get("message_id") message = Message.objects.get(pk=message_id) - return render(request, - 'messager/single_message.html', {'message': message}) + return render(request, "messager/single_message.html", {"message": message}) diff --git a/bootcamp/news/admin.py b/bootcamp/news/admin.py index f6e609d36..2bfe2797f 100755 --- a/bootcamp/news/admin.py +++ b/bootcamp/news/admin.py @@ -4,5 +4,5 @@ @admin.register(News) class NewseAdmin(admin.ModelAdmin): - list_display = ('content', 'user', 'reply') - list_filter = ('timestamp', 'reply') + list_display = ("content", "user", "reply") + list_filter = ("timestamp", "reply") diff --git a/bootcamp/news/apps.py b/bootcamp/news/apps.py index 0086fbb18..b2a4c66ac 100755 --- a/bootcamp/news/apps.py +++ b/bootcamp/news/apps.py @@ -3,5 +3,5 @@ class NewsConfig(AppConfig): - name = 'bootcamp.news' + name = "bootcamp.news" verbose_name = _("News") diff --git a/bootcamp/news/migrations/0001_initial.py b/bootcamp/news/migrations/0001_initial.py index 7ae2f338a..fdee0f4a4 100755 --- a/bootcamp/news/migrations/0001_initial.py +++ b/bootcamp/news/migrations/0001_initial.py @@ -10,26 +10,59 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( - name='News', + name="News", fields=[ - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('content', models.TextField(max_length=280)), - ('reply', models.BooleanField(default=False, verbose_name='Is a reply?')), - ('liked', models.ManyToManyField(blank=True, related_name='liked_news', to=settings.AUTH_USER_MODEL)), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='thread', to='news.News')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='publisher', to=settings.AUTH_USER_MODEL)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("content", models.TextField(max_length=280)), + ( + "reply", + models.BooleanField(default=False, verbose_name="Is a reply?"), + ), + ( + "liked", + models.ManyToManyField( + blank=True, + related_name="liked_news", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="thread", + to="news.News", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="publisher", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'News', - 'verbose_name_plural': 'News', - 'ordering': ('-timestamp',), + "verbose_name": "News", + "verbose_name_plural": "News", + "ordering": ("-timestamp",), }, - ), + ) ] diff --git a/bootcamp/news/tests/test_models.py b/bootcamp/news/tests/test_models.py index 1f93a632f..453d592a4 100755 --- a/bootcamp/news/tests/test_models.py +++ b/bootcamp/news/tests/test_models.py @@ -8,18 +8,16 @@ def setUp(self): self.user = self.make_user("test_user") self.other_user = self.make_user("other_test_user") self.first_news = News.objects.create( - user=self.user, - content="This is a short content." + user=self.user, content="This is a short content." ) self.second_news = News.objects.create( - user=self.user, - content="This the second content." + user=self.user, content="This the second content." ) self.third_news = News.objects.create( user=self.other_user, content="This is an answer to the first news.", reply=True, - parent=self.first_news + parent=self.first_news, ) def test_reply_this(self): diff --git a/bootcamp/news/tests/test_views.py b/bootcamp/news/tests/test_views.py index e1aab18d9..d6b5139a2 100755 --- a/bootcamp/news/tests/test_views.py +++ b/bootcamp/news/tests/test_views.py @@ -15,18 +15,16 @@ def setUp(self): self.client.login(username="first_user", password="password") self.other_client.login(username="second_user", password="password") self.first_news = News.objects.create( - user=self.user, - content="This is a short content." + user=self.user, content="This is a short content." ) self.second_news = News.objects.create( - user=self.user, - content="This the second content." + user=self.user, content="This the second content." ) self.third_news = News.objects.create( user=self.other_user, content="This is an answer to the first news.", reply=True, - parent=self.first_news + parent=self.first_news, ) def test_news_list(self): @@ -39,15 +37,18 @@ def test_news_list(self): def test_delete_news(self): initial_count = News.objects.count() response = self.client.post( - reverse("news:delete_news", kwargs={"pk": self.second_news.pk})) + reverse("news:delete_news", kwargs={"pk": self.second_news.pk}) + ) assert response.status_code == 302 assert News.objects.count() == initial_count - 1 def test_post_news(self): initial_count = News.objects.count() response = self.client.post( - reverse("news:post_news"), {"post": "This a third element."}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') + reverse("news:post_news"), + {"post": "This a third element."}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response.status_code == 200 assert News.objects.count() == initial_count + 1 @@ -55,7 +56,8 @@ def test_like_news(self): response = self.client.post( reverse("news:like_post"), {"news": self.first_news.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response.status_code == 200 assert self.first_news.count_likers() == 1 assert self.user in self.first_news.get_likers() @@ -65,7 +67,8 @@ def test_thread(self): response = self.client.get( reverse("news:get_thread"), {"news": self.first_news.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response.status_code == 200 assert response.json()["uuid"] == str(self.first_news.pk) assert "This is a short content." in response.json()["news"] @@ -74,11 +77,9 @@ def test_thread(self): def test_posting_comments(self): response = self.client.post( reverse("news:post_comments"), - { - "reply": "This a third element.", - "parent": self.second_news.pk - }, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') + {"reply": "This a third element.", "parent": self.second_news.pk}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response.status_code == 200 assert response.json()["comments"] == 1 @@ -86,22 +87,23 @@ def test_updating_interactions(self): first_response = self.client.post( reverse("news:like_post"), {"news": self.first_news.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) second_response = self.other_client.post( reverse("news:like_post"), {"news": self.first_news.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) third_response = self.client.post( reverse("news:post_comments"), - { - "reply": "This a third element.", - "parent": self.first_news.pk - }, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') + {"reply": "This a third element.", "parent": self.first_news.pk}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) fourth_response = self.client.post( reverse("news:update_interactions"), {"id_value": self.first_news.pk}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert first_response.status_code == 200 assert second_response.status_code == 200 assert third_response.status_code == 200 diff --git a/bootcamp/news/urls.py b/bootcamp/news/urls.py index a554ccbd7..6ead7432c 100755 --- a/bootcamp/news/urls.py +++ b/bootcamp/news/urls.py @@ -2,14 +2,17 @@ from bootcamp.news import views -app_name = 'news' +app_name = "news" urlpatterns = [ - url(r'^$', views.NewsListView.as_view(), name='list'), - url(r'^delete/(?P[-\w]+)/$', - views.NewsDeleteView.as_view(), name='delete_news'), - url(r'^post-news/$', views.post_news, name='post_news'), - url(r'^like/$', views.like, name='like_post'), - url(r'^get-thread/$', views.get_thread, name='get_thread'), - url(r'^post-comment/$', views.post_comment, name='post_comments'), - url(r'^update-interactions/$', views.update_interactions, name='update_interactions'), + url(r"^$", views.NewsListView.as_view(), name="list"), + url( + r"^delete/(?P[-\w]+)/$", views.NewsDeleteView.as_view(), name="delete_news" + ), + url(r"^post-news/$", views.post_news, name="post_news"), + url(r"^like/$", views.like, name="like_post"), + url(r"^get-thread/$", views.get_thread, name="get_thread"), + url(r"^post-comment/$", views.post_comment, name="post_comments"), + url( + r"^update-interactions/$", views.update_interactions, name="update_interactions" + ), ] diff --git a/bootcamp/news/views.py b/bootcamp/news/views.py index ee2ca6aaf..e849362cc 100755 --- a/bootcamp/news/views.py +++ b/bootcamp/news/views.py @@ -13,6 +13,7 @@ class NewsListView(LoginRequiredMixin, ListView): """A really simple ListView, with some JS magic on the UI.""" + model = News paginate_by = 15 @@ -23,6 +24,7 @@ def get_queryset(self, **kwargs): class NewsDeleteView(LoginRequiredMixin, AuthorRequiredMixin, DeleteView): """Implementation of the DeleteView overriding the delete method to allow a no-redirect response to use with AJAX call.""" + model = News success_url = reverse_lazy("news:list") @@ -34,25 +36,20 @@ def post_news(request): """A function view to implement the post functionality with AJAX allowing to create News instances as parent ones.""" user = request.user - post = request.POST['post'] + post = request.POST["post"] post = post.strip() if 0 < len(post) <= 280: - posted = News.objects.create( - user=user, - content=post, - ) + posted = News.objects.create(user=user, content=post) html = render_to_string( - 'news/news_single.html', - { - 'news': posted, - 'request': request - }) + "news/news_single.html", {"news": posted, "request": request} + ) return HttpResponse(html) else: length = len(post) - 280 return HttpResponseBadRequest( - content=_(f'Text is {length} characters longer than accepted.')) + content=_(f"Text is {length} characters longer than accepted.") + ) @login_required @@ -61,7 +58,7 @@ def post_news(request): def like(request): """Function view to receive AJAX, returns the count of likes a given news has recieved.""" - news_id = request.POST['news'] + news_id = request.POST["news"] news = News.objects.get(pk=news_id) user = request.user news.switch_like(user) @@ -73,16 +70,13 @@ def like(request): @require_http_methods(["GET"]) def get_thread(request): """Returns a list of news with the given news as parent.""" - news_id = request.GET['news'] + news_id = request.GET["news"] news = News.objects.get(pk=news_id) news_html = render_to_string("news/news_single.html", {"news": news}) thread_html = render_to_string( - "news/news_thread.html", {"thread": news.get_thread()}) - return JsonResponse({ - "uuid": news_id, - "news": news_html, - "thread": thread_html, - }) + "news/news_thread.html", {"thread": news.get_thread()} + ) + return JsonResponse({"uuid": news_id, "news": news_html, "thread": thread_html}) @login_required @@ -93,13 +87,13 @@ def post_comment(request): News instances who happens to be the children and commenters of the root post.""" user = request.user - post = request.POST['reply'] - par = request.POST['parent'] + post = request.POST["reply"] + par = request.POST["parent"] parent = News.objects.get(pk=par) post = post.strip() if post: parent.reply_this(user, post) - return JsonResponse({'comments': parent.count_thread()}) + return JsonResponse({"comments": parent.count_thread()}) else: return HttpResponseBadRequest() @@ -109,7 +103,7 @@ def post_comment(request): @ajax_required @require_http_methods(["POST"]) def update_interactions(request): - data_point = request.POST['id_value'] + data_point = request.POST["id_value"] news = News.objects.get(pk=data_point) - data = {'likes': news.count_likers(), 'comments': news.count_thread()} + data = {"likes": news.count_likers(), "comments": news.count_thread()} return JsonResponse(data) diff --git a/bootcamp/notifications/admin.py b/bootcamp/notifications/admin.py index be7fa0b27..ce9e0f7fd 100755 --- a/bootcamp/notifications/admin.py +++ b/bootcamp/notifications/admin.py @@ -4,5 +4,5 @@ @admin.register(Notification) class NotificationAdmin(admin.ModelAdmin): - list_display = ('recipient', 'actor', 'verb', 'unread', ) - list_filter = ('recipient', 'unread', ) + list_display = ("recipient", "actor", "verb", "unread") + list_filter = ("recipient", "unread") diff --git a/bootcamp/notifications/apps.py b/bootcamp/notifications/apps.py index 394767f2c..1dd04c24a 100755 --- a/bootcamp/notifications/apps.py +++ b/bootcamp/notifications/apps.py @@ -3,5 +3,5 @@ class NotificationsConfig(AppConfig): - name = 'bootcamp.notifications' - verbose_name = _('Notifications') + name = "bootcamp.notifications" + verbose_name = _("Notifications") diff --git a/bootcamp/notifications/consumers.py b/bootcamp/notifications/consumers.py index f27912e31..77b257019 100755 --- a/bootcamp/notifications/consumers.py +++ b/bootcamp/notifications/consumers.py @@ -7,6 +7,7 @@ class NotificationsConsumer(AsyncWebsocketConsumer): """Consumer to manage WebSocket connections for the Notification app, called when the websocket is handshaking as part of initial connection. """ + async def connect(self): """Consumer Connect implementation, to validate user status and prevent non authenticated user to take advante from the connection.""" @@ -16,15 +17,13 @@ async def connect(self): else: # Accept the connection - await self.channel_layer.group_add( - 'notifications', self.channel_name) + await self.channel_layer.group_add("notifications", self.channel_name) await self.accept() async def disconnect(self, close_code): """Consumer implementation to leave behind the group at the moment the closes the connection.""" - await self.channel_layer.group_discard( - 'notifications', self.channel_name) + await self.channel_layer.group_discard("notifications", self.channel_name) async def receive(self, text_data): """Receive method implementation to redirect any new message received diff --git a/bootcamp/notifications/migrations/0001_initial.py b/bootcamp/notifications/migrations/0001_initial.py index c98505d24..5af68e2dd 100755 --- a/bootcamp/notifications/migrations/0001_initial.py +++ b/bootcamp/notifications/migrations/0001_initial.py @@ -11,28 +11,82 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Notification', + name="Notification", fields=[ - ('unread', models.BooleanField(db_index=True, default=True)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('slug', models.SlugField(blank=True, max_length=210, null=True)), - ('verb', models.CharField(choices=[('L', 'liked'), ('C', 'commented'), ('F', 'cavorited'), ('A', 'answered'), ('W', 'accepted'), ('E', 'edited'), ('K', 'also commented'), ('I', 'logged in'), ('O', 'logged out'), ('V', 'voted on'), ('S', 'shared'), ('U', 'created an account'), ('R', 'replied to')], max_length=1)), - ('action_object_object_id', models.CharField(blank=True, max_length=50, null=True)), - ('action_object_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notify_action_object', to='contenttypes.ContentType')), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notify_actor', to=settings.AUTH_USER_MODEL)), - ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ("unread", models.BooleanField(db_index=True, default=True)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("slug", models.SlugField(blank=True, max_length=210, null=True)), + ( + "verb", + models.CharField( + choices=[ + ("L", "liked"), + ("C", "commented"), + ("F", "cavorited"), + ("A", "answered"), + ("W", "accepted"), + ("E", "edited"), + ("K", "also commented"), + ("I", "logged in"), + ("O", "logged out"), + ("V", "voted on"), + ("S", "shared"), + ("U", "created an account"), + ("R", "replied to"), + ], + max_length=1, + ), + ), + ( + "action_object_object_id", + models.CharField(blank=True, max_length=50, null=True), + ), + ( + "action_object_content_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notify_action_object", + to="contenttypes.ContentType", + ), + ), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notify_actor", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Notification', - 'verbose_name_plural': 'Notifications', - 'ordering': ('-timestamp',), + "verbose_name": "Notification", + "verbose_name_plural": "Notifications", + "ordering": ("-timestamp",), }, - ), + ) ] diff --git a/bootcamp/notifications/models.py b/bootcamp/notifications/models.py index fbb198767..ee3985b02 100755 --- a/bootcamp/notifications/models.py +++ b/bootcamp/notifications/models.py @@ -83,52 +83,60 @@ class Notification(models.Model): <1 minute ago>
    <2 hours ago> """ - LIKED = 'L' - COMMENTED = 'C' - FAVORITED = 'F' - ANSWERED = 'A' - ACCEPTED_ANSWER = 'W' - EDITED_ARTICLE = 'E' - ALSO_COMMENTED = 'K' - LOGGED_IN = 'I' - LOGGED_OUT = 'O' - VOTED = 'V' - SHARED = 'S' - SIGNUP = 'U' - REPLY = 'R' + + LIKED = "L" + COMMENTED = "C" + FAVORITED = "F" + ANSWERED = "A" + ACCEPTED_ANSWER = "W" + EDITED_ARTICLE = "E" + ALSO_COMMENTED = "K" + LOGGED_IN = "I" + LOGGED_OUT = "O" + VOTED = "V" + SHARED = "S" + SIGNUP = "U" + REPLY = "R" NOTIFICATION_TYPES = ( - (LIKED, _('liked')), - (COMMENTED, _('commented')), - (FAVORITED, _('cavorited')), - (ANSWERED, _('answered')), - (ACCEPTED_ANSWER, _('accepted')), - (EDITED_ARTICLE, _('edited')), - (ALSO_COMMENTED, _('also commented')), - (LOGGED_IN, _('logged in')), - (LOGGED_OUT, _('logged out')), - (VOTED, _('voted on')), - (SHARED, _('shared')), - (SIGNUP, _('created an account')), - (REPLY, _('replied to')) - ) - actor = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name="notify_actor", - on_delete=models.CASCADE) - recipient = models.ForeignKey(settings.AUTH_USER_MODEL, blank=False, - related_name="notifications", on_delete=models.CASCADE) + (LIKED, _("liked")), + (COMMENTED, _("commented")), + (FAVORITED, _("cavorited")), + (ANSWERED, _("answered")), + (ACCEPTED_ANSWER, _("accepted")), + (EDITED_ARTICLE, _("edited")), + (ALSO_COMMENTED, _("also commented")), + (LOGGED_IN, _("logged in")), + (LOGGED_OUT, _("logged out")), + (VOTED, _("voted on")), + (SHARED, _("shared")), + (SIGNUP, _("created an account")), + (REPLY, _("replied to")), + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, related_name="notify_actor", on_delete=models.CASCADE + ) + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=False, + related_name="notifications", + on_delete=models.CASCADE, + ) unread = models.BooleanField(default=True, db_index=True) timestamp = models.DateTimeField(auto_now_add=True) - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) slug = models.SlugField(max_length=210, null=True, blank=True) verb = models.CharField(max_length=1, choices=NOTIFICATION_TYPES) - action_object_content_type = models.ForeignKey(ContentType, - blank=True, null=True, related_name="notify_action_object", - on_delete=models.CASCADE) - action_object_object_id = models.CharField( - max_length=50, blank=True, null=True) + action_object_content_type = models.ForeignKey( + ContentType, + blank=True, + null=True, + related_name="notify_action_object", + on_delete=models.CASCADE, + ) + action_object_object_id = models.CharField(max_length=50, blank=True, null=True) action_object = GenericForeignKey( - "action_object_content_type", "action_object_object_id") + "action_object_content_type", "action_object_object_id" + ) objects = NotificationQuerySet.as_manager() class Meta: @@ -138,14 +146,17 @@ class Meta: def __str__(self): if self.action_object: - return f'{self.actor} {self.get_verb_display()} {self.action_object} {self.time_since()} ago' + return f"{self.actor} {self.get_verb_display()} {self.action_object} {self.time_since()} ago" - return f'{self.actor} {self.get_verb_display()} {self.time_since()} ago' + return f"{self.actor} {self.get_verb_display()} {self.time_since()} ago" def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(f'{self.recipient} {self.uuid_id} {self.verb}', - to_lower=True, max_length=200) + self.slug = slugify( + f"{self.recipient} {self.uuid_id} {self.verb}", + to_lower=True, + max_length=200, + ) super().save(*args, **kwargs) @@ -162,32 +173,32 @@ def get_icon(self): """Model method to validate notification type and return the closest icon to the verb. """ - if self.verb == 'C' or self.verb == 'A' or self.verb == 'K': - return 'fa-comment' + if self.verb == "C" or self.verb == "A" or self.verb == "K": + return "fa-comment" - elif self.verb == 'I' or self.verb == 'U' or self.verb == 'O': - return 'fa-users' + elif self.verb == "I" or self.verb == "U" or self.verb == "O": + return "fa-users" - elif self.verb == 'L': - return 'fa-heart' + elif self.verb == "L": + return "fa-heart" - elif self.verb == 'F': - return 'fa-star' + elif self.verb == "F": + return "fa-star" - elif self.verb == 'W': - return 'fa-check-circle' + elif self.verb == "W": + return "fa-check-circle" - elif self.verb == 'E': - return 'fa-pencil' + elif self.verb == "E": + return "fa-pencil" - elif self.verb == 'V': - return 'fa-plus' + elif self.verb == "V": + return "fa-plus" - elif self.verb == 'S': - return 'fa-share-alt' + elif self.verb == "S": + return "fa-share-alt" - elif self.verb == 'R': - return 'fa-reply' + elif self.verb == "R": + return "fa-reply" def mark_as_read(self): if self.unread: @@ -214,16 +225,16 @@ def notification_handler(actor, recipient, verb, **kwargs): :param key: String defining what kind of notification is going to be created. :param id_value: UUID value assigned to a specific element in the DOM. """ - key = kwargs.pop('key', 'notification') - id_value = kwargs.pop('id_value', None) - if recipient == 'global': + key = kwargs.pop("key", "notification") + id_value = kwargs.pop("id_value", None) + if recipient == "global": users = get_user_model().objects.all().exclude(username=actor.username) for user in users: Notification.objects.create( actor=actor, recipient=user, verb=verb, - action_object=kwargs.pop('action_object', None) + action_object=kwargs.pop("action_object", None), ) notification_broadcast(actor, key) @@ -233,7 +244,7 @@ def notification_handler(actor, recipient, verb, **kwargs): actor=actor, recipient=get_user_model().objects.get(username=user), verb=verb, - action_object=kwargs.pop('action_object', None) + action_object=kwargs.pop("action_object", None), ) elif isinstance(recipient, get_user_model()): @@ -241,10 +252,11 @@ def notification_handler(actor, recipient, verb, **kwargs): actor=actor, recipient=recipient, verb=verb, - action_object=kwargs.pop('action_object', None) + action_object=kwargs.pop("action_object", None), ) notification_broadcast( - actor, key, id_value=id_value, recipient=recipient.username) + actor, key, id_value=id_value, recipient=recipient.username + ) else: pass @@ -264,13 +276,13 @@ def notification_broadcast(actor, key, **kwargs): notified. """ channel_layer = get_channel_layer() - id_value = kwargs.pop('id_value', None) - recipient = kwargs.pop('recipient', None) + id_value = kwargs.pop("id_value", None) + recipient = kwargs.pop("recipient", None) payload = { - 'type': 'receive', - 'key': key, - 'actor_name': actor.username, - 'id_value': id_value, - 'recipient': recipient - } - async_to_sync(channel_layer.group_send)('notifications', payload) + "type": "receive", + "key": key, + "actor_name": actor.username, + "id_value": id_value, + "recipient": recipient, + } + async_to_sync(channel_layer.group_send)("notifications", payload) diff --git a/bootcamp/notifications/tests/test_models.py b/bootcamp/notifications/tests/test_models.py index 9ea96d226..850644310 100755 --- a/bootcamp/notifications/tests/test_models.py +++ b/bootcamp/notifications/tests/test_models.py @@ -9,20 +9,14 @@ def setUp(self): self.user = self.make_user("test_user") self.other_user = self.make_user("other_test_user") self.first_notification = Notification.objects.create( - actor=self.user, - recipient=self.other_user, - verb="L" - ) + actor=self.user, recipient=self.other_user, verb="L" + ) self.second_notification = Notification.objects.create( - actor=self.user, - recipient=self.other_user, - verb="C" - ) + actor=self.user, recipient=self.other_user, verb="C" + ) self.third_notification = Notification.objects.create( - actor=self.other_user, - recipient=self.user, - verb="A" - ) + actor=self.other_user, recipient=self.user, verb="A" + ) def test_return_values(self): assert isinstance(self.first_notification, Notification) @@ -58,10 +52,7 @@ def test_get_most_recent(self): def test_single_notification(self): Notification.objects.mark_all_as_read() - obj = News.objects.create( - user=self.user, - content="This is a short content." - ) + obj = News.objects.create(user=self.user, content="This is a short content.") notification_handler(self.user, self.other_user, "C", action_object=obj) assert Notification.objects.unread().count() == 1 diff --git a/bootcamp/notifications/tests/test_views.py b/bootcamp/notifications/tests/test_views.py index f96ac1fd5..89efcda8c 100755 --- a/bootcamp/notifications/tests/test_views.py +++ b/bootcamp/notifications/tests/test_views.py @@ -15,20 +15,14 @@ def setUp(self): self.client.login(username="first_user", password="password") self.other_client.login(username="second_user", password="password") self.first_notification = Notification.objects.create( - actor=self.user, - recipient=self.other_user, - verb="L" - ) + actor=self.user, recipient=self.other_user, verb="L" + ) self.second_notification = Notification.objects.create( - actor=self.user, - recipient=self.other_user, - verb="C" - ) + actor=self.user, recipient=self.other_user, verb="C" + ) self.third_notification = Notification.objects.create( - actor=self.other_user, - recipient=self.user, - verb="A" - ) + actor=self.other_user, recipient=self.user, verb="A" + ) def test_notification_list(self): response = self.client.get(reverse("notifications:unread")) @@ -37,12 +31,16 @@ def test_notification_list(self): def test_mark_all_as_read(self): response = self.client.get(reverse("notifications:mark_all_read"), follow=True) - assert '/notifications/' in str(response.context["request"]) + assert "/notifications/" in str(response.context["request"]) assert Notification.objects.unread().count() == 2 def test_mark_as_read(self): response = self.client.get( - reverse("notifications:mark_as_read", kwargs={"slug": self.first_notification.slug})) + reverse( + "notifications:mark_as_read", + kwargs={"slug": self.first_notification.slug}, + ) + ) assert response.status_code == 302 assert Notification.objects.unread().count() == 2 diff --git a/bootcamp/notifications/urls.py b/bootcamp/notifications/urls.py index 813cce56f..3593fb82e 100755 --- a/bootcamp/notifications/urls.py +++ b/bootcamp/notifications/urls.py @@ -2,10 +2,14 @@ from bootcamp.notifications import views -app_name = 'notifications' +app_name = "notifications" urlpatterns = [ - url(r'^$', views.NotificationUnreadListView.as_view(), name='unread'), - url(r'^mark-as-read/(?P[-\w]+)/$', views.mark_as_read, name='mark_as_read'), - url(r'^mark-all-as-read/$', views.mark_all_as_read, name='mark_all_read'), - url(r'^latest-notifications/$', views.get_latest_notifications, name='latest_notifications'), + url(r"^$", views.NotificationUnreadListView.as_view(), name="unread"), + url(r"^mark-as-read/(?P[-\w]+)/$", views.mark_as_read, name="mark_as_read"), + url(r"^mark-all-as-read/$", views.mark_all_as_read, name="mark_all_read"), + url( + r"^latest-notifications/$", + views.get_latest_notifications, + name="latest_notifications", + ), ] diff --git a/bootcamp/notifications/views.py b/bootcamp/notifications/views.py index 9392081b0..7fcd3ef4b 100755 --- a/bootcamp/notifications/views.py +++ b/bootcamp/notifications/views.py @@ -11,9 +11,10 @@ class NotificationUnreadListView(LoginRequiredMixin, ListView): """Basic ListView implementation to show the unread notifications for the actual user""" + model = Notification - context_object_name = 'notification_list' - template_name = 'notifications/notification_list.html' + context_object_name = "notification_list" + template_name = "notifications/notification_list.html" def get_queryset(self, **kwargs): return self.request.user.notifications.unread() @@ -24,15 +25,17 @@ def mark_all_as_read(request): """View to call the model method which marks as read all the notifications directed to the actual user.""" request.user.notifications.mark_all_as_read() - _next = request.GET.get('next') + _next = request.GET.get("next") messages.add_message( - request, messages.SUCCESS, - _(f'All notifications to {request.user.username} have been marked as read.')) + request, + messages.SUCCESS, + _(f"All notifications to {request.user.username} have been marked as read."), + ) if _next: return redirect(_next) - return redirect('notifications:unread') + return redirect("notifications:unread") @login_required @@ -44,19 +47,21 @@ def mark_as_read(request, slug=None): notification.mark_as_read() messages.add_message( - request, messages.SUCCESS, - _(f'The notification {notification.slug} has been marked as read.')) - _next = request.GET.get('next') + request, + messages.SUCCESS, + _(f"The notification {notification.slug} has been marked as read."), + ) + _next = request.GET.get("next") if _next: return redirect(_next) - return redirect('notifications:unread') + return redirect("notifications:unread") @login_required def get_latest_notifications(request): notifications = request.user.notifications.get_most_recent() - return render(request, - 'notifications/most_recent.html', - {'notifications': notifications}) + return render( + request, "notifications/most_recent.html", {"notifications": notifications} + ) diff --git a/bootcamp/qa/apps.py b/bootcamp/qa/apps.py index 17909cc29..7a2617150 100755 --- a/bootcamp/qa/apps.py +++ b/bootcamp/qa/apps.py @@ -3,5 +3,5 @@ class QaConfig(AppConfig): - name = 'bootcamp.qa' - verbose_name = _('Q&A') + name = "bootcamp.qa" + verbose_name = _("Q&A") diff --git a/bootcamp/qa/migrations/0001_initial.py b/bootcamp/qa/migrations/0001_initial.py index 16ebf7c98..34849feea 100755 --- a/bootcamp/qa/migrations/0001_initial.py +++ b/bootcamp/qa/migrations/0001_initial.py @@ -13,78 +13,137 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('taggit', '0002_auto_20150616_2121'), + ("taggit", "0002_auto_20150616_2121"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ migrations.CreateModel( - name='Answer', + name="Answer", fields=[ - ('content', markdownx.models.MarkdownxField()), - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('total_votes', models.IntegerField(default=0)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('is_answer', models.BooleanField(default=False)), + ("content", markdownx.models.MarkdownxField()), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("total_votes", models.IntegerField(default=0)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("is_answer", models.BooleanField(default=False)), ], options={ - 'verbose_name': 'Answer', - 'verbose_name_plural': 'Answers', - 'ordering': ['-is_answer', '-timestamp'], + "verbose_name": "Answer", + "verbose_name_plural": "Answers", + "ordering": ["-is_answer", "-timestamp"], }, ), migrations.CreateModel( - name='Question', + name="Question", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=200, unique=True)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('slug', models.SlugField(blank=True, max_length=80, null=True)), - ('status', models.CharField(choices=[('O', 'Open'), ('C', 'Closed'), ('D', 'Draft')], default='D', max_length=1)), - ('content', markdownx.models.MarkdownxField()), - ('has_answer', models.BooleanField(default=False)), - ('total_votes', models.IntegerField(default=0)), - ('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=200, unique=True)), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("slug", models.SlugField(blank=True, max_length=80, null=True)), + ( + "status", + models.CharField( + choices=[("O", "Open"), ("C", "Closed"), ("D", "Draft")], + default="D", + max_length=1, + ), + ), + ("content", markdownx.models.MarkdownxField()), + ("has_answer", models.BooleanField(default=False)), + ("total_votes", models.IntegerField(default=0)), + ( + "tags", + taggit.managers.TaggableManager( + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Question', - 'verbose_name_plural': 'Questions', - 'ordering': ['-timestamp'], + "verbose_name": "Question", + "verbose_name_plural": "Questions", + "ordering": ["-timestamp"], }, ), migrations.CreateModel( - name='Vote', + name="Vote", fields=[ - ('uuid_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('timestamp', models.DateTimeField(auto_now_add=True)), - ('value', models.BooleanField(default=True)), - ('object_id', models.CharField(blank=True, max_length=50, null=True)), - ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='votes_on', to='contenttypes.ContentType')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "uuid_id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ("value", models.BooleanField(default=True)), + ("object_id", models.CharField(blank=True, max_length=50, null=True)), + ( + "content_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="votes_on", + to="contenttypes.ContentType", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], - options={ - 'verbose_name': 'Vote', - 'verbose_name_plural': 'Votes', - }, + options={"verbose_name": "Vote", "verbose_name_plural": "Votes"}, ), migrations.AddField( - model_name='answer', - name='question', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='qa.Question'), + model_name="answer", + name="question", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="qa.Question" + ), ), migrations.AddField( - model_name='answer', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + model_name="answer", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), ), migrations.AlterUniqueTogether( - name='vote', - unique_together={('user', 'content_type', 'object_id')}, + name="vote", unique_together={("user", "content_type", "object_id")} ), migrations.AlterIndexTogether( - name='vote', - index_together={('content_type', 'object_id')}, + name="vote", index_together={("content_type", "object_id")} ), ] diff --git a/bootcamp/qa/models.py b/bootcamp/qa/models.py index b043d28a2..849f1e568 100755 --- a/bootcamp/qa/models.py +++ b/bootcamp/qa/models.py @@ -18,18 +18,20 @@ class Vote(models.Model): """Model class to host every vote, made with ContentType framework to allow a single model connected to Questions and Answers.""" - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) timestamp = models.DateTimeField(auto_now_add=True) value = models.BooleanField(default=True) - content_type = models.ForeignKey(ContentType, - blank=True, null=True, related_name="votes_on", on_delete=models.CASCADE) - object_id = models.CharField( - max_length=50, blank=True, null=True) - vote = GenericForeignKey( - "content_type", "object_id") + content_type = models.ForeignKey( + ContentType, + blank=True, + null=True, + related_name="votes_on", + on_delete=models.CASCADE, + ) + object_id = models.CharField(max_length=50, blank=True, null=True) + vote = GenericForeignKey("content_type", "object_id") class Meta: verbose_name = _("Vote") @@ -54,7 +56,7 @@ def get_unanswered(self): def get_counted_tags(self): """Returns a dict element with tags and its count to show on the UI.""" tag_dict = {} - query = self.all().annotate(tagged=Count('tags')).filter(tags__gt=0) + query = self.all().annotate(tagged=Count("tags")).filter(tags__gt=0) for obj in query: for tag in obj.tags.names(): if tag not in tag_dict: @@ -68,14 +70,11 @@ def get_counted_tags(self): class Question(models.Model): """Model class to contain every question in the forum.""" + OPEN = "O" CLOSED = "C" DRAFT = "D" - STATUS = ( - (OPEN, _("Open")), - (CLOSED, _("Closed")), - (DRAFT, _("Draft")), - ) + STATUS = ((OPEN, _("Open")), (CLOSED, _("Closed")), (DRAFT, _("Draft"))) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) title = models.CharField(max_length=200, unique=True, blank=False) timestamp = models.DateTimeField(auto_now_add=True) @@ -95,8 +94,7 @@ class Meta: def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(f"{self.title}-{self.id}", - to_lower=True, max_length=80) + self.slug = slugify(f"{self.title}-{self.id}", to_lower=True, max_length=80) super().save(*args, **kwargs) @@ -135,11 +133,11 @@ def get_markdown(self): class Answer(models.Model): """Model class to contain every answer in the forum and to link it to its respective question.""" + question = models.ForeignKey(Question, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) content = MarkdownxField() - uuid_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False) + uuid_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) total_votes = models.IntegerField(default=0) timestamp = models.DateTimeField(auto_now_add=True) is_answer = models.BooleanField(default=False) @@ -160,7 +158,9 @@ def count_votes(self): """Method to update the sum of the total votes. Uses this complex query to avoid race conditions at database level.""" dic = Counter(self.votes.values_list("value", flat=True)) - Answer.objects.filter(uuid_id=self.uuid_id).update(total_votes=dic[True] - dic[False]) + Answer.objects.filter(uuid_id=self.uuid_id).update( + total_votes=dic[True] - dic[False] + ) self.refresh_from_db() def get_upvoters(self): diff --git a/bootcamp/qa/tests/test_models.py b/bootcamp/qa/tests/test_models.py index 280c88a2a..58cfd649f 100755 --- a/bootcamp/qa/tests/test_models.py +++ b/bootcamp/qa/tests/test_models.py @@ -8,9 +8,10 @@ def setUp(self): self.user = self.make_user("test_user") self.other_user = self.make_user("other_test_user") self.question_one = Question.objects.create( - user=self.user, title="This is a sample question", + user=self.user, + title="This is a sample question", content="This is a sample question content", - tags="test1, test2" + tags="test1, test2", ) self.question_two = Question.objects.create( user=self.user, @@ -21,45 +22,49 @@ def setUp(self): know than nobody wants to publish a test, just a test; everybody always wants the real deal.""", has_answer=True, - tags="test1, test2" + tags="test1, test2", ) self.answer = Answer.objects.create( user=self.user, question=self.question_two, content="A reaaaaally loooong content", - is_answer=True + is_answer=True, ) def test_can_vote_question(self): self.question_one.votes.update_or_create( - user=self.user, defaults={"value": True}, ) + user=self.user, defaults={"value": True} + ) self.question_one.votes.update_or_create( - user=self.other_user, defaults={"value": True}) + user=self.other_user, defaults={"value": True} + ) self.question_one.count_votes() assert self.question_one.total_votes == 2 def test_can_vote_answer(self): + self.answer.votes.update_or_create(user=self.user, defaults={"value": True}) self.answer.votes.update_or_create( - user=self.user, defaults={"value": True}, ) - self.answer.votes.update_or_create( - user=self.other_user, defaults={"value": True}, ) + user=self.other_user, defaults={"value": True} + ) self.answer.count_votes() assert self.answer.total_votes == 2 def test_get_question_voters(self): self.question_one.votes.update_or_create( - user=self.user, defaults={"value": True}, ) + user=self.user, defaults={"value": True} + ) self.question_one.votes.update_or_create( - user=self.other_user, defaults={"value": False}) + user=self.other_user, defaults={"value": False} + ) self.question_one.count_votes() assert self.user in self.question_one.get_upvoters() assert self.other_user in self.question_one.get_downvoters() def test_get_answern_voters(self): + self.answer.votes.update_or_create(user=self.user, defaults={"value": True}) self.answer.votes.update_or_create( - user=self.user, defaults={"value": True}, ) - self.answer.votes.update_or_create( - user=self.other_user, defaults={"value": False}) + user=self.other_user, defaults={"value": False} + ) self.answer.count_votes() assert self.user in self.answer.get_upvoters() assert self.other_user in self.answer.get_downvoters() @@ -91,17 +96,17 @@ def test_answer_accept_method(self): answer_one = Answer.objects.create( user=self.user, question=self.question_one, - content="A reaaaaally loooonger content" + content="A reaaaaally loooonger content", ) answer_two = Answer.objects.create( user=self.user, question=self.question_one, - content="A reaaaaally even loooonger content" + content="A reaaaaally even loooonger content", ) answer_three = Answer.objects.create( user=self.user, question=self.question_one, - content="Even a reaaaaally loooonger content" + content="Even a reaaaaally loooonger content", ) self.assertFalse(answer_one.is_answer) self.assertFalse(answer_two.is_answer) diff --git a/bootcamp/qa/tests/test_views.py b/bootcamp/qa/tests/test_views.py index 720f22cf5..1ede6b79d 100755 --- a/bootcamp/qa/tests/test_views.py +++ b/bootcamp/qa/tests/test_views.py @@ -15,9 +15,10 @@ def setUp(self): self.client.login(username="first_user", password="password") self.other_client.login(username="second_user", password="password") self.question_one = Question.objects.create( - user=self.user, title="This is a sample question", + user=self.user, + title="This is a sample question", content="This is a sample question content", - tags="test1, test2" + tags="test1, test2", ) self.question_two = Question.objects.create( user=self.user, @@ -28,13 +29,13 @@ def setUp(self): know than nobody wants to publish a test, just a test; everybody always wants the real deal.""", has_answer=True, - tags="test1, test2" + tags="test1, test2", ) self.answer = Answer.objects.create( user=self.user, question=self.question_two, content="A reaaaaally loooong content", - is_answer=True + is_answer=True, ) def test_index_questions(self): @@ -44,11 +45,15 @@ def test_index_questions(self): def test_create_question_view(self): current_count = Question.objects.count() - response = self.client.post(reverse("qa:ask_question"), - {"title": "Not much of a title", - "content": "bablababla bablababla", - "status": "O", - "tags": "test, tag"}) + response = self.client.post( + reverse("qa:ask_question"), + { + "title": "Not much of a title", + "content": "bablababla bablababla", + "status": "O", + "tags": "test, tag", + }, + ) assert response.status_code == 302 new_question = Question.objects.first() assert new_question.title == "Not much of a title" @@ -67,9 +72,8 @@ def test_unanswered_questions(self): def test_answer_question(self): current_answer_count = Answer.objects.count() response = self.client.post( - reverse("qa:propose_answer", - kwargs={"question_id": self.question_one.id}), - {"content": "A reaaaaally loooong content"} + reverse("qa:propose_answer", kwargs={"question_id": self.question_one.id}), + {"content": "A reaaaaally loooong content"}, ) assert response.status_code == 302 assert Answer.objects.count() == current_answer_count + 1 @@ -78,33 +82,38 @@ def test_question_upvote(self): response_one = self.client.post( reverse("qa:question_vote"), {"value": "U", "question": self.question_one.id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response_one.status_code == 200 def test_question_downvote(self): response_one = self.client.post( reverse("qa:question_vote"), {"value": "D", "question": self.question_one.id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response_one.status_code == 200 def test_answer_upvote(self): response_one = self.client.post( reverse("qa:answer_vote"), {"value": "U", "answer": self.answer.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response_one.status_code == 200 def test_answer_downvote(self): response_one = self.client.post( reverse("qa:answer_vote"), {"value": "D", "answer": self.answer.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response_one.status_code == 200 def test_accept_answer(self): response_one = self.client.post( reverse("qa:accept_answer"), {"answer": self.answer.uuid_id}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest") + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) assert response_one.status_code == 200 diff --git a/bootcamp/qa/urls.py b/bootcamp/qa/urls.py index 9067f7ecb..3b5d057b9 100755 --- a/bootcamp/qa/urls.py +++ b/bootcamp/qa/urls.py @@ -2,15 +2,23 @@ from bootcamp.qa import views -app_name = 'qa' +app_name = "qa" urlpatterns = [ - url(r'^$', views.QuestionListView.as_view(), name='index_noans'), - url(r'^answered/$', views.QuestionAnsListView.as_view(), name='index_ans'), - url(r'^indexed/$', views.QuestionsIndexListView.as_view(), name='index_all'), - url(r'^question-detail/(?P\d+)/$', views.QuestionDetailView.as_view(), name='question_detail'), - url(r'^ask-question/$', views.CreateQuestionView.as_view(), name='ask_question'), - url(r'^propose-answer/(?P\d+)/$', views.CreateAnswerView.as_view(), name='propose_answer'), - url(r'^question/vote/$', views.question_vote, name='question_vote'), - url(r'^answer/vote/$', views.answer_vote, name='answer_vote'), - url(r'^accept-answer/$', views.accept_answer, name='accept_answer'), + url(r"^$", views.QuestionListView.as_view(), name="index_noans"), + url(r"^answered/$", views.QuestionAnsListView.as_view(), name="index_ans"), + url(r"^indexed/$", views.QuestionsIndexListView.as_view(), name="index_all"), + url( + r"^question-detail/(?P\d+)/$", + views.QuestionDetailView.as_view(), + name="question_detail", + ), + url(r"^ask-question/$", views.CreateQuestionView.as_view(), name="ask_question"), + url( + r"^propose-answer/(?P\d+)/$", + views.CreateAnswerView.as_view(), + name="propose_answer", + ), + url(r"^question/vote/$", views.question_vote, name="question_vote"), + url(r"^answer/vote/$", views.answer_vote, name="answer_vote"), + url(r"^accept-answer/$", views.accept_answer, name="accept_answer"), ] diff --git a/bootcamp/qa/views.py b/bootcamp/qa/views.py index e42d5da25..10a98f4bf 100755 --- a/bootcamp/qa/views.py +++ b/bootcamp/qa/views.py @@ -15,6 +15,7 @@ class QuestionsIndexListView(LoginRequiredMixin, ListView): """CBV to render a list view with all the registered questions.""" + model = Question paginate_by = 20 context_object_name = "questions" @@ -29,6 +30,7 @@ def get_context_data(self, *args, **kwargs): class QuestionAnsListView(QuestionsIndexListView): """CBV to render a list view with all question which have been already marked as answered.""" + def get_queryset(self, **kwargs): return Question.objects.get_answered() @@ -41,6 +43,7 @@ def get_context_data(self, *args, **kwargs): class QuestionListView(QuestionsIndexListView): """CBV to render a list view with all question which haven't been marked as answered.""" + def get_queryset(self, **kwargs): return Question.objects.get_unanswered() @@ -53,6 +56,7 @@ def get_context_data(self, *args, **kwargs): class QuestionDetailView(LoginRequiredMixin, DetailView): """View to call a given Question object and to render all the details about that Question.""" + model = Question context_object_name = "question" @@ -61,6 +65,7 @@ class CreateQuestionView(LoginRequiredMixin, CreateView): """ View to handle the creation of a new question """ + form_class = QuestionForm template_name = "qa/question_form.html" message = _("Your question has been created.") @@ -78,8 +83,9 @@ class CreateAnswerView(LoginRequiredMixin, CreateView): """ View to create new answers for a given question """ + model = Answer - fields = ["content", ] + fields = ["content"] message = _("Thank you! Your answer has been posted.") def form_valid(self, form): @@ -89,8 +95,7 @@ def form_valid(self, form): def get_success_url(self): messages.success(self.request, self.message) - return reverse( - "qa:question_detail", kwargs={"pk": self.kwargs["question_id"]}) + return reverse("qa:question_detail", kwargs={"pk": self.kwargs["question_id"]}) @login_required @@ -109,15 +114,14 @@ def question_vote(request): question = Question.objects.get(pk=question_id) try: - question.votes.update_or_create( - user=request.user, defaults={"value": value}, ) + question.votes.update_or_create(user=request.user, defaults={"value": value}) question.count_votes() return JsonResponse({"votes": question.total_votes}) except IntegrityError: # pragma: no cover - return JsonResponse({'status': 'false', - 'message': _("Database integrity error.")}, - status=500) + return JsonResponse( + {"status": "false", "message": _("Database integrity error.")}, status=500 + ) @login_required @@ -136,15 +140,14 @@ def answer_vote(request): answer = Answer.objects.get(uuid_id=answer_id) try: - answer.votes.update_or_create( - user=request.user, defaults={"value": value}, ) + answer.votes.update_or_create(user=request.user, defaults={"value": value}) answer.count_votes() return JsonResponse({"votes": answer.total_votes}) except IntegrityError: # pragma: no cover - return JsonResponse({'status': 'false', - 'message': _("Database integrity error.")}, - status=500) + return JsonResponse( + {"status": "false", "message": _("Database integrity error.")}, status=500 + ) @login_required @@ -156,4 +159,4 @@ def accept_answer(request): answer_id = request.POST["answer"] answer = Answer.objects.get(uuid_id=answer_id) answer.accept_answer() - return JsonResponse({'status': 'true'}, status=200) + return JsonResponse({"status": "true"}, status=200) diff --git a/bootcamp/search/apps.py b/bootcamp/search/apps.py index 535cc8173..e7ebada17 100755 --- a/bootcamp/search/apps.py +++ b/bootcamp/search/apps.py @@ -3,5 +3,5 @@ class SearchConfig(AppConfig): - name = 'bootcamp.search' - verbose_name = _('Search') + name = "bootcamp.search" + verbose_name = _("Search") diff --git a/bootcamp/search/tests/test_views.py b/bootcamp/search/tests/test_views.py index da5f0f383..61f8536c9 100755 --- a/bootcamp/search/tests/test_views.py +++ b/bootcamp/search/tests/test_views.py @@ -13,6 +13,7 @@ class SearchViewsTests(TestCase): Includes tests for all the functionality associated with Views """ + def setUp(self): self.user = self.make_user("first_user") self.other_user = self.make_user("second_user") @@ -27,16 +28,25 @@ def setUp(self): wants to publish a test, just a test; everybody always wants the real deal.""" self.article = Article.objects.create( - user=self.user, title="A really nice first title", - content=self.content, tags="list, lists", status="P") - self.article_2 = Article.objects.create(user=self.other_user, - title="A first bad title", - content="First bad content", - tags="bad", status="P") + user=self.user, + title="A really nice first title", + content=self.content, + tags="list, lists", + status="P", + ) + self.article_2 = Article.objects.create( + user=self.other_user, + title="A first bad title", + content="First bad content", + tags="bad", + status="P", + ) self.question_one = Question.objects.create( - user=self.user, title="This is the first sample question", + user=self.user, + title="This is the first sample question", content="This is a sample question description for the first time", - tags="test1,test2") + tags="test1,test2", + ) self.question_two = Question.objects.create( user=self.user, title="The first shortes title", @@ -45,14 +55,15 @@ def setUp(self): publish it first, because they know this is just a test, and you know than nobody wants to publish a test, just a test; everybody always wants the real deal.""", - has_answer=True, tags="test1,test2" + has_answer=True, + tags="test1,test2", + ) + self.news_one = News.objects.create( + user=self.user, content="This is the first lazy content." ) - self.news_one = News.objects.create(user=self.user, - content="This is the first lazy content.") def test_news_search_results(self): - response = self.client.get( - reverse("search:results"), {'query': 'This is'}) + response = self.client.get(reverse("search:results"), {"query": "This is"}) assert response.status_code == 200 assert self.news_one in response.context["news_list"] assert self.question_one in response.context["questions_list"] @@ -61,10 +72,12 @@ def test_news_search_results(self): def test_questions_suggestions_results(self): response = self.client.get( - reverse("search:suggestions"), {'term': 'first'}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - assert response.json()[0]['value'] == "first_user" - assert response.json()[1]['value'] == "A first bad title" - assert response.json()[2]['value'] == "A really nice first title" - assert response.json()[3]['value'] == "The first shortes title" - assert response.json()[4]['value'] == "This is the first sample question" + reverse("search:suggestions"), + {"term": "first"}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + assert response.json()[0]["value"] == "first_user" + assert response.json()[1]["value"] == "A first bad title" + assert response.json()[2]["value"] == "A really nice first title" + assert response.json()[3]["value"] == "The first shortes title" + assert response.json()[4]["value"] == "This is the first sample question" diff --git a/bootcamp/search/urls.py b/bootcamp/search/urls.py index be661d33b..1d6633dd4 100755 --- a/bootcamp/search/urls.py +++ b/bootcamp/search/urls.py @@ -2,8 +2,8 @@ from bootcamp.search import views -app_name = 'search' +app_name = "search" urlpatterns = [ - url(r'^$', views.SearchListView.as_view(), name='results'), - url(r'^suggestions/$', views.get_suggestions, name='suggestions'), + url(r"^$", views.SearchListView.as_view(), name="results"), + url(r"^suggestions/$", views.get_suggestions, name="suggestions"), ] diff --git a/bootcamp/search/views.py b/bootcamp/search/views.py index 5064c3d62..6aeb828fa 100755 --- a/bootcamp/search/views.py +++ b/bootcamp/search/views.py @@ -15,34 +15,47 @@ class SearchListView(LoginRequiredMixin, ListView): """CBV to contain all the search results""" + model = News template_name = "search/search_results.html" def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) query = self.request.GET.get("query") - context["active"] = 'news' + context["active"] = "news" context["hide_search"] = True context["tags_list"] = Tag.objects.filter(name=query) context["news_list"] = News.objects.filter( - content__icontains=query, reply=False).distinct() - context["articles_list"] = Article.objects.filter(Q( - title__icontains=query) | Q(content__icontains=query) | Q( - tags__name__icontains=query), status="P").distinct() + content__icontains=query, reply=False + ).distinct() + context["articles_list"] = Article.objects.filter( + Q(title__icontains=query) + | Q(content__icontains=query) + | Q(tags__name__icontains=query), + status="P", + ).distinct() context["questions_list"] = Question.objects.filter( - Q(title__icontains=query) | Q(content__icontains=query) | Q( - tags__name__icontains=query)).distinct() - context["users_list"] = get_user_model().objects.filter( - Q(username__icontains=query) | Q( - name__icontains=query)).distinct() + Q(title__icontains=query) + | Q(content__icontains=query) + | Q(tags__name__icontains=query) + ).distinct() + context["users_list"] = ( + get_user_model() + .objects.filter(Q(username__icontains=query) | Q(name__icontains=query)) + .distinct() + ) context["news_count"] = context["news_list"].count() context["articles_count"] = context["articles_list"].count() context["questions_count"] = context["questions_list"].count() context["users_count"] = context["users_list"].count() context["tags_count"] = context["tags_list"].count() - context["total_results"] = context["news_count"] + \ - context["articles_count"] + context["questions_count"] + \ - context["users_count"] + context["tags_count"] + context["total_results"] = ( + context["news_count"] + + context["articles_count"] + + context["questions_count"] + + context["users_count"] + + context["tags_count"] + ) return context @@ -52,14 +65,27 @@ def get_context_data(self, *args, **kwargs): def get_suggestions(request): # Convert users, articles, questions objects into list to be # represented as a single list. - query = request.GET.get('term', '') - users = list(get_user_model().objects.filter( - Q(username__icontains=query) | Q(name__icontains=query))) - articles = list(Article.objects.filter( - Q(title__icontains=query) | Q(content__icontains=query) | Q( - tags__name__icontains=query), status="P")) - questions = list(Question.objects.filter(Q(title__icontains=query) | Q( - content__icontains=query) | Q(tags__name__icontains=query))) + query = request.GET.get("term", "") + users = list( + get_user_model().objects.filter( + Q(username__icontains=query) | Q(name__icontains=query) + ) + ) + articles = list( + Article.objects.filter( + Q(title__icontains=query) + | Q(content__icontains=query) + | Q(tags__name__icontains=query), + status="P", + ) + ) + questions = list( + Question.objects.filter( + Q(title__icontains=query) + | Q(content__icontains=query) + | Q(tags__name__icontains=query) + ) + ) # Add all the retrieved users, articles, questions to data_retrieved # list. data_retrieved = users @@ -69,19 +95,19 @@ def get_suggestions(request): for data in data_retrieved: data_json = {} if isinstance(data, get_user_model()): - data_json['id'] = data.id - data_json['label'] = data.username - data_json['value'] = data.username + data_json["id"] = data.id + data_json["label"] = data.username + data_json["value"] = data.username if isinstance(data, Article): - data_json['id'] = data.id - data_json['label'] = data.title - data_json['value'] = data.title + data_json["id"] = data.id + data_json["label"] = data.title + data_json["value"] = data.title if isinstance(data, Question): - data_json['id'] = data.id - data_json['label'] = data.title - data_json['value'] = data.title + data_json["id"] = data.id + data_json["label"] = data.title + data_json["value"] = data.title results.append(data_json) diff --git a/bootcamp/users/adapters.py b/bootcamp/users/adapters.py index b31450aee..bc5b290f3 100755 --- a/bootcamp/users/adapters.py +++ b/bootcamp/users/adapters.py @@ -5,9 +5,9 @@ class AccountAdapter(DefaultAccountAdapter): def is_open_for_signup(self, request): - return getattr(settings, 'ACCOUNT_ALLOW_REGISTRATION', True) + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) class SocialAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request, sociallogin): - return getattr(settings, 'ACCOUNT_ALLOW_REGISTRATION', True) + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) diff --git a/bootcamp/users/admin.py b/bootcamp/users/admin.py index e9427e059..d395d5362 100755 --- a/bootcamp/users/admin.py +++ b/bootcamp/users/admin.py @@ -13,9 +13,9 @@ class Meta(UserChangeForm.Meta): class MyUserCreationForm(UserCreationForm): - error_message = UserCreationForm.error_messages.update({ - 'duplicate_username': 'This username has already been taken.' - }) + error_message = UserCreationForm.error_messages.update( + {"duplicate_username": "This username has already been taken."} + ) class Meta(UserCreationForm.Meta): model = User @@ -28,15 +28,13 @@ def clean_username(self): except User.DoesNotExist: return username - raise forms.ValidationError(self.error_messages['duplicate_username']) + raise forms.ValidationError(self.error_messages["duplicate_username"]) @admin.register(User) class MyUserAdmin(AuthUserAdmin): form = MyUserChangeForm add_form = MyUserCreationForm - fieldsets = ( - ('User Profile', {'fields': ('name',)}), - ) + AuthUserAdmin.fieldsets - list_display = ('username', 'name', 'is_superuser') - search_fields = ['name'] + fieldsets = (("User Profile", {"fields": ("name",)}),) + AuthUserAdmin.fieldsets + list_display = ("username", "name", "is_superuser") + search_fields = ["name"] diff --git a/bootcamp/users/apps.py b/bootcamp/users/apps.py index 476a289b5..d64326584 100755 --- a/bootcamp/users/apps.py +++ b/bootcamp/users/apps.py @@ -3,7 +3,7 @@ class UsersConfig(AppConfig): - name = 'bootcamp.users' + name = "bootcamp.users" verbose_name = _("Users") def ready(self): diff --git a/bootcamp/users/migrations/0001_initial.py b/bootcamp/users/migrations/0001_initial.py index 252c68e79..27f964e9a 100755 --- a/bootcamp/users/migrations/0001_initial.py +++ b/bootcamp/users/migrations/0001_initial.py @@ -10,46 +10,206 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ('auth', '0009_alter_user_last_name_max_length'), - ] + dependencies = [("auth", "0009_alter_user_last_name_max_length")] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('name', models.CharField(blank=True, max_length=255, verbose_name="User's name")), - ('picture', models.ImageField(blank=True, null=True, upload_to='profile_pics/', verbose_name='Profile picture')), - ('location', models.CharField(blank=True, max_length=50, null=True, verbose_name='Location')), - ('job_title', models.CharField(blank=True, max_length=50, null=True, verbose_name='Job title')), - ('personal_url', models.URLField(blank=True, max_length=555, null=True, verbose_name='Personal URL')), - ('facebook_account', models.URLField(blank=True, max_length=255, null=True, verbose_name='Facebook profile')), - ('twitter_account', models.URLField(blank=True, max_length=255, null=True, verbose_name='Twitter account')), - ('github_account', models.URLField(blank=True, max_length=255, null=True, verbose_name='GitHub profile')), - ('linkedin_account', models.URLField(blank=True, max_length=255, null=True, verbose_name='LinkedIn profile')), - ('short_bio', models.CharField(blank=True, max_length=60, null=True, verbose_name='Describe yourself')), - ('bio', models.CharField(blank=True, max_length=280, null=True, verbose_name='Short bio')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=30, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "name", + models.CharField( + blank=True, max_length=255, verbose_name="User's name" + ), + ), + ( + "picture", + models.ImageField( + blank=True, + null=True, + upload_to="profile_pics/", + verbose_name="Profile picture", + ), + ), + ( + "location", + models.CharField( + blank=True, max_length=50, null=True, verbose_name="Location" + ), + ), + ( + "job_title", + models.CharField( + blank=True, max_length=50, null=True, verbose_name="Job title" + ), + ), + ( + "personal_url", + models.URLField( + blank=True, + max_length=555, + null=True, + verbose_name="Personal URL", + ), + ), + ( + "facebook_account", + models.URLField( + blank=True, + max_length=255, + null=True, + verbose_name="Facebook profile", + ), + ), + ( + "twitter_account", + models.URLField( + blank=True, + max_length=255, + null=True, + verbose_name="Twitter account", + ), + ), + ( + "github_account", + models.URLField( + blank=True, + max_length=255, + null=True, + verbose_name="GitHub profile", + ), + ), + ( + "linkedin_account", + models.URLField( + blank=True, + max_length=255, + null=True, + verbose_name="LinkedIn profile", + ), + ), + ( + "short_bio", + models.CharField( + blank=True, + max_length=60, + null=True, + verbose_name="Describe yourself", + ), + ), + ( + "bio", + models.CharField( + blank=True, max_length=280, null=True, verbose_name="Short bio" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), ], options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), + managers=[("objects", django.contrib.auth.models.UserManager())], + ) ] diff --git a/bootcamp/users/models.py b/bootcamp/users/models.py index 0812ba152..42556bf17 100755 --- a/bootcamp/users/models.py +++ b/bootcamp/users/models.py @@ -11,31 +11,35 @@ class User(AbstractUser): # First Name and Last Name do not cover name patterns around the globe. name = models.CharField(_("User's name"), blank=True, max_length=255) picture = models.ImageField( - _('Profile picture'), upload_to='profile_pics/', null=True, blank=True) - location = models.CharField( - _('Location'), max_length=50, null=True, blank=True) - job_title = models.CharField( - _('Job title'), max_length=50, null=True, blank=True) + _("Profile picture"), upload_to="profile_pics/", null=True, blank=True + ) + location = models.CharField(_("Location"), max_length=50, null=True, blank=True) + job_title = models.CharField(_("Job title"), max_length=50, null=True, blank=True) personal_url = models.URLField( - _('Personal URL'), max_length=555, blank=True, null=True) + _("Personal URL"), max_length=555, blank=True, null=True + ) facebook_account = models.URLField( - _('Facebook profile'), max_length=255, blank=True, null=True) + _("Facebook profile"), max_length=255, blank=True, null=True + ) twitter_account = models.URLField( - _('Twitter account'), max_length=255, blank=True, null=True) + _("Twitter account"), max_length=255, blank=True, null=True + ) github_account = models.URLField( - _('GitHub profile'), max_length=255, blank=True, null=True) + _("GitHub profile"), max_length=255, blank=True, null=True + ) linkedin_account = models.URLField( - _('LinkedIn profile'), max_length=255, blank=True, null=True) + _("LinkedIn profile"), max_length=255, blank=True, null=True + ) short_bio = models.CharField( - _('Describe yourself'), max_length=60, blank=True, null=True) - bio = models.CharField( - _('Short bio'), max_length=280, blank=True, null=True) + _("Describe yourself"), max_length=60, blank=True, null=True + ) + bio = models.CharField(_("Short bio"), max_length=280, blank=True, null=True) def __str__(self): return self.username def get_absolute_url(self): - return reverse('users:detail', kwargs={'username': self.username}) + return reverse("users:detail", kwargs={"username": self.username}) def get_profile_name(self): if self.name: diff --git a/bootcamp/users/schema.py b/bootcamp/users/schema.py index 83e4533c8..5bcb4560c 100755 --- a/bootcamp/users/schema.py +++ b/bootcamp/users/schema.py @@ -6,6 +6,7 @@ class UserType(DjangoObjectType): """DjangoObjectType to acces the User model.""" + picture = graphene.String() name = graphene.String() @@ -33,7 +34,7 @@ def resolve_all_users(self, info, **kwargs): return User.objects.all() def resolve_user(self, info, **kwargs): - id = kwargs.get('id') + id = kwargs.get("id") if id is not None: return User.objects.get(id=id) diff --git a/bootcamp/users/tests/test_admin.py b/bootcamp/users/tests/test_admin.py index a1ff0b846..1afcbfda3 100755 --- a/bootcamp/users/tests/test_admin.py +++ b/bootcamp/users/tests/test_admin.py @@ -4,32 +4,35 @@ class TestMyUserCreationForm(TestCase): - def setUp(self): - self.user = self.make_user('notalamode', 'notalamodespassword') + self.user = self.make_user("notalamode", "notalamodespassword") def test_clean_username_success(self): # Instantiate the form with a new username - form = MyUserCreationForm({ - 'username': 'alamode', - 'password1': '7jefB#f@Cc7YJB]2v', - 'password2': '7jefB#f@Cc7YJB]2v', - }) + form = MyUserCreationForm( + { + "username": "alamode", + "password1": "7jefB#f@Cc7YJB]2v", + "password2": "7jefB#f@Cc7YJB]2v", + } + ) # Run is_valid() to trigger the validation valid = form.is_valid() self.assertTrue(valid) # Run the actual clean_username method username = form.clean_username() - self.assertEqual('alamode', username) + self.assertEqual("alamode", username) def test_clean_username_false(self): # Instantiate the form with the same username as self.user - form = MyUserCreationForm({ - 'username': self.user.username, - 'password1': 'notalamodespassword', - 'password2': 'notalamodespassword', - }) + form = MyUserCreationForm( + { + "username": self.user.username, + "password1": "notalamodespassword", + "password2": "notalamodespassword", + } + ) # Run is_valid() to trigger the validation, which is going to fail # because the username is already taken valid = form.is_valid() @@ -37,4 +40,4 @@ def test_clean_username_false(self): # The form.errors dict should contain a single error called 'username' self.assertTrue(len(form.errors) == 1) - self.assertTrue('username' in form.errors) + self.assertTrue("username" in form.errors) diff --git a/bootcamp/users/tests/test_models.py b/bootcamp/users/tests/test_models.py index fcefea173..7c8e1c50e 100755 --- a/bootcamp/users/tests/test_models.py +++ b/bootcamp/users/tests/test_models.py @@ -2,21 +2,17 @@ class TestUser(TestCase): - def setUp(self): self.user = self.make_user() def test__str__(self): self.assertEqual( self.user.__str__(), - "testuser" # This is the default username for self.make_user() + "testuser", # This is the default username for self.make_user() ) def test_get_absolute_url(self): - self.assertEqual( - self.user.get_absolute_url(), - "/users/testuser/" - ) + self.assertEqual(self.user.get_absolute_url(), "/users/testuser/") def test_get_profile_name(self): assert self.user.get_profile_name() == "testuser" diff --git a/bootcamp/users/tests/test_urls.py b/bootcamp/users/tests/test_urls.py index 4935b0f3e..6b072436d 100755 --- a/bootcamp/users/tests/test_urls.py +++ b/bootcamp/users/tests/test_urls.py @@ -11,41 +11,34 @@ def setUp(self): def test_list_reverse(self): """users:list should reverse to /users/.""" - self.assertEqual(reverse('users:list'), '/users/') + self.assertEqual(reverse("users:list"), "/users/") def test_list_resolve(self): """/users/ should resolve to users:list.""" - self.assertEqual(resolve('/users/').view_name, 'users:list') + self.assertEqual(resolve("/users/").view_name, "users:list") def test_redirect_reverse(self): """users:redirect should reverse to /users/~redirect/.""" - self.assertEqual(reverse('users:redirect'), '/users/~redirect/') + self.assertEqual(reverse("users:redirect"), "/users/~redirect/") def test_redirect_resolve(self): """/users/~redirect/ should resolve to users:redirect.""" - self.assertEqual( - resolve('/users/~redirect/').view_name, - 'users:redirect' - ) + self.assertEqual(resolve("/users/~redirect/").view_name, "users:redirect") def test_detail_reverse(self): """users:detail should reverse to /users/testuser/.""" self.assertEqual( - reverse('users:detail', kwargs={'username': 'testuser'}), - '/users/testuser/' + reverse("users:detail", kwargs={"username": "testuser"}), "/users/testuser/" ) def test_detail_resolve(self): """/users/testuser/ should resolve to users:detail.""" - self.assertEqual(resolve('/users/testuser/').view_name, 'users:detail') + self.assertEqual(resolve("/users/testuser/").view_name, "users:detail") def test_update_reverse(self): """users:update should reverse to /users/~update/.""" - self.assertEqual(reverse('users:update'), '/users/~update/') + self.assertEqual(reverse("users:update"), "/users/~update/") def test_update_resolve(self): """/users/~update/ should resolve to users:update.""" - self.assertEqual( - resolve('/users/~update/').view_name, - 'users:update' - ) + self.assertEqual(resolve("/users/~update/").view_name, "users:update") diff --git a/bootcamp/users/tests/test_views.py b/bootcamp/users/tests/test_views.py index 350034e9c..e4545a9f0 100755 --- a/bootcamp/users/tests/test_views.py +++ b/bootcamp/users/tests/test_views.py @@ -2,47 +2,38 @@ from test_plus.test import TestCase -from ..views import ( - UserRedirectView, - UserUpdateView -) +from ..views import UserRedirectView, UserUpdateView class BaseUserTestCase(TestCase): - def setUp(self): self.user = self.make_user() self.factory = RequestFactory() class TestUserRedirectView(BaseUserTestCase): - def test_get_redirect_url(self): # Instantiate the view directly. Never do this outside a test! view = UserRedirectView() # Generate a fake request - request = self.factory.get('/fake-url') + request = self.factory.get("/fake-url") # Attach the user to the request request.user = self.user # Attach the request to the view view.request = request # Expect: '/users/testuser/', as that is the default username for # self.make_user() - self.assertEqual( - view.get_redirect_url(), - '/users/testuser/' - ) + self.assertEqual(view.get_redirect_url(), "/users/testuser/") class TestUserUpdateView(BaseUserTestCase): - def setUp(self): # call BaseUserTestCase.setUp() super().setUp() # Instantiate the view directly. Never do this outside a test! self.view = UserUpdateView() # Generate a fake request - request = self.factory.get('/fake-url') + request = self.factory.get("/fake-url") # Attach the user to the request request.user = self.user # Attach the request to the view @@ -51,14 +42,8 @@ def setUp(self): def test_get_success_url(self): # Expect: '/users/testuser/', as that is the default username for # self.make_user() - self.assertEqual( - self.view.get_success_url(), - '/users/testuser/' - ) + self.assertEqual(self.view.get_success_url(), "/users/testuser/") def test_get_object(self): # Expect: self.user, as that is the request's user object - self.assertEqual( - self.view.get_object(), - self.user - ) + self.assertEqual(self.view.get_object(), self.user) diff --git a/bootcamp/users/urls.py b/bootcamp/users/urls.py index 1e1618363..69414f47e 100755 --- a/bootcamp/users/urls.py +++ b/bootcamp/users/urls.py @@ -2,26 +2,14 @@ from . import views -app_name = 'users' +app_name = "users" urlpatterns = [ + url(regex=r"^$", view=views.UserListView.as_view(), name="list"), + url(regex=r"^~redirect/$", view=views.UserRedirectView.as_view(), name="redirect"), + url(regex=r"^~update/$", view=views.UserUpdateView.as_view(), name="update"), url( - regex=r'^$', - view=views.UserListView.as_view(), - name='list' - ), - url( - regex=r'^~redirect/$', - view=views.UserRedirectView.as_view(), - name='redirect' - ), - url( - regex=r'^~update/$', - view=views.UserUpdateView.as_view(), - name='update' - ), - url( - regex=r'^(?P[\w.@+-]+)/$', + regex=r"^(?P[\w.@+-]+)/$", view=views.UserDetailView.as_view(), - name='detail' + name="detail", ), ] diff --git a/bootcamp/users/views.py b/bootcamp/users/views.py index 224eccb6c..d670fdcd2 100755 --- a/bootcamp/users/views.py +++ b/bootcamp/users/views.py @@ -8,28 +8,37 @@ class UserDetailView(LoginRequiredMixin, DetailView): model = User # These next two lines tell the view to index lookups by username - slug_field = 'username' - slug_url_kwarg = 'username' + slug_field = "username" + slug_url_kwarg = "username" class UserRedirectView(LoginRequiredMixin, RedirectView): permanent = False def get_redirect_url(self): - return reverse('users:detail', - kwargs={'username': self.request.user.username}) + return reverse("users:detail", kwargs={"username": self.request.user.username}) class UserUpdateView(LoginRequiredMixin, UpdateView): - fields = ['name', 'email', 'picture', 'job_title', 'location', 'personal_url', - 'facebook_account', 'twitter_account', 'github_account', - 'linkedin_account', 'short_bio', 'bio', ] + fields = [ + "name", + "email", + "picture", + "job_title", + "location", + "personal_url", + "facebook_account", + "twitter_account", + "github_account", + "linkedin_account", + "short_bio", + "bio", + ] model = User # send the user back to their own page after a successful update def get_success_url(self): - return reverse('users:detail', - kwargs={'username': self.request.user.username}) + return reverse("users:detail", kwargs={"username": self.request.user.username}) def get_object(self): # Only get the User record for the user making the request @@ -39,5 +48,5 @@ def get_object(self): class UserListView(LoginRequiredMixin, ListView): model = User # These next two lines tell the view to index lookups by username - slug_field = 'username' - slug_url_kwarg = 'username' + slug_field = "username" + slug_url_kwarg = "username" diff --git a/config/asgi.py b/config/asgi.py index 9263f4251..658d2a2a0 100755 --- a/config/asgi.py +++ b/config/asgi.py @@ -7,6 +7,6 @@ import django from channels.routing import get_default_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") django.setup() application = get_default_application() diff --git a/config/routing.py b/config/routing.py index 5b9ccddd5..01a726c14 100755 --- a/config/routing.py +++ b/config/routing.py @@ -6,16 +6,21 @@ from bootcamp.messager.consumers import MessagerConsumer from bootcamp.notifications.consumers import NotificationsConsumer + # from bootcamp.notifications.routing import notifications_urlpatterns # from bootcamp.messager.routing import messager_urlpatterns -application = ProtocolTypeRouter({ - "websocket": AllowedHostsOriginValidator( - AuthMiddlewareStack( - URLRouter([ - url(r'^notifications/$', NotificationsConsumer), - url(r'^(?P[^/]+)/$', MessagerConsumer), - ]) - ), - ), -}) +application = ProtocolTypeRouter( + { + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack( + URLRouter( + [ + url(r"^notifications/$", NotificationsConsumer), + url(r"^(?P[^/]+)/$", MessagerConsumer), + ] + ) + ) + ) + } +) diff --git a/config/settings/base.py b/config/settings/base.py index 269d0f764..ecad77e50 100755 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -4,11 +4,13 @@ import environ -ROOT_DIR = environ.Path(__file__) - 3 # (bootcamp/config/settings/base.py - 3 = bootcamp/) -APPS_DIR = ROOT_DIR.path('bootcamp') +ROOT_DIR = ( + environ.Path(__file__) - 3 +) # (bootcamp/config/settings/base.py - 3 = bootcamp/) +APPS_DIR = ROOT_DIR.path("bootcamp") env = environ.Env() -env.read_env(str(ROOT_DIR.path('.env'))) +env.read_env(str(ROOT_DIR.path(".env"))) # READ_DOT_ENV_FILE = env.bool('DJANGO_READ_DOT_ENV_FILE', default=False) # if READ_DOT_ENV_FILE: @@ -18,14 +20,14 @@ # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = env.bool('DJANGO_DEBUG', False) +DEBUG = env.bool("DJANGO_DEBUG", False) # Local time zone. Choices are # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # though not all of them may be available with every OS. # In Windows, this must be set to your system time zone. -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" # https://docs.djangoproject.com/en/dev/ref/settings/#language-code -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" # https://docs.djangoproject.com/en/dev/ref/settings/#site-id SITE_ID = 1 # https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n @@ -38,144 +40,132 @@ # DATABASES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#databases -DATABASES = { - 'default': env.db('DATABASE_URL'), -} -DATABASES['default']['ATOMIC_REQUESTS'] = True +DATABASES = {"default": env.db("DATABASE_URL")} +DATABASES["default"]["ATOMIC_REQUESTS"] = True # URLS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf -ROOT_URLCONF = 'config.urls' +ROOT_URLCONF = "config.urls" # https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application -WSGI_APPLICATION = 'config.wsgi.application' +WSGI_APPLICATION = "config.wsgi.application" # APPS # ------------------------------------------------------------------------------ DJANGO_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.humanize', - 'django.contrib.admin', - 'django.forms', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", + "django.contrib.admin", + "django.forms", ] THIRD_PARTY_APPS = [ - 'crispy_forms', - 'sorl.thumbnail', - 'allauth', - 'allauth.account', - 'allauth.socialaccount', + "crispy_forms", + "sorl.thumbnail", + "allauth", + "allauth.account", + "allauth.socialaccount", # 'allauth.socialaccount.providers.amazon', # 'allauth.socialaccount.providers.github', # 'allauth.socialaccount.providers.google', # 'allauth.socialaccount.providers.linkedin', # 'allauth.socialaccount.providers.slack', - 'channels', - 'django_comments', - 'graphene_django', - 'markdownx', - 'taggit', + "channels", + "django_comments", + "graphene_django", + "markdownx", + "taggit", ] LOCAL_APPS = [ - 'bootcamp.users.apps.UsersConfig', + "bootcamp.users.apps.UsersConfig", # Your stuff: custom apps go here - 'bootcamp.articles.apps.ArticlesConfig', - 'bootcamp.messager.apps.MessagerConfig', - 'bootcamp.news.apps.NewsConfig', - 'bootcamp.notifications.apps.NotificationsConfig', - 'bootcamp.qa.apps.QaConfig', - 'bootcamp.search.apps.SearchConfig' + "bootcamp.articles.apps.ArticlesConfig", + "bootcamp.messager.apps.MessagerConfig", + "bootcamp.news.apps.NewsConfig", + "bootcamp.notifications.apps.NotificationsConfig", + "bootcamp.qa.apps.QaConfig", + "bootcamp.search.apps.SearchConfig", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS -FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" # MIGRATIONS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules -MIGRATION_MODULES = { - 'sites': 'bootcamp.contrib.sites.migrations' -} +MIGRATION_MODULES = {"sites": "bootcamp.contrib.sites.migrations"} # AUTHENTICATION # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", ] # https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model -AUTH_USER_MODEL = 'users.User' +AUTH_USER_MODEL = "users.User" # https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url -LOGIN_REDIRECT_URL = 'news:list' +LOGIN_REDIRECT_URL = "news:list" # https://docs.djangoproject.com/en/dev/ref/settings/#login-url -LOGIN_URL = 'account_login' +LOGIN_URL = "account_login" # PASSWORDS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers PASSWORD_HASHERS = [ # https://docs.djangoproject.com/en/dev/topics/auth/passwords/#using-argon2-with-django - 'django.contrib.auth.hashers.Argon2PasswordHasher', - 'django.contrib.auth.hashers.PBKDF2PasswordHasher', - 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', - 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', - 'django.contrib.auth.hashers.BCryptPasswordHasher', + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", + "django.contrib.auth.hashers.BCryptPasswordHasher", ] # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] # MIDDLEWARE # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#middleware MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] # STATIC # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#static-root -STATIC_ROOT = str(ROOT_DIR('staticfiles')) +STATIC_ROOT = str(ROOT_DIR("staticfiles")) # https://docs.djangoproject.com/en/dev/ref/settings/#static-url -STATIC_URL = '/static/' +STATIC_URL = "/static/" # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS -STATICFILES_DIRS = [ - str(APPS_DIR.path('static')), -] +STATICFILES_DIRS = [str(APPS_DIR.path("static"))] # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders STATICFILES_FINDERS = [ - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] # MEDIA # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#media-root -MEDIA_ROOT = str(ROOT_DIR('media')) +MEDIA_ROOT = str(ROOT_DIR("media")) # https://docs.djangoproject.com/en/dev/ref/settings/#media-url -MEDIA_URL = '/media/' +MEDIA_URL = "/media/" # TEMPLATES # ------------------------------------------------------------------------------ @@ -183,53 +173,51 @@ TEMPLATES = [ { # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND - 'BACKEND': 'django.template.backends.django.DjangoTemplates', + "BACKEND": "django.template.backends.django.DjangoTemplates", # https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs - 'DIRS': [ - str(APPS_DIR.path('templates')), - ], - 'OPTIONS': { + "DIRS": [str(APPS_DIR.path("templates"))], + "OPTIONS": { # https://docs.djangoproject.com/en/dev/ref/settings/#template-debug - 'debug': DEBUG, + "debug": DEBUG, # https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types - 'loaders': [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", ], # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", ], }, - }, + } ] # http://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs -CRISPY_TEMPLATE_PACK = 'bootstrap4' +CRISPY_TEMPLATE_PACK = "bootstrap4" # FIXTURES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs -FIXTURE_DIRS = ( - str(APPS_DIR.path('fixtures')), -) +FIXTURE_DIRS = (str(APPS_DIR.path("fixtures")),) # EMAIL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend -EMAIL_BACKEND = env('EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend') +EMAIL_BACKEND = env( + "EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend" +) # ADMIN # ------------------------------------------------------------------------------ # Django Admin URL regex. -ADMIN_URL = r'^admin/' +ADMIN_URL = r"^admin/" # https://docs.djangoproject.com/en/dev/ref/settings/#admins # ADMINS = [ # ("""Vitor Freitas""", 'vitor-freitas@example.com'), @@ -240,17 +228,17 @@ # django-allauth # ------------------------------------------------------------------------------ -ACCOUNT_ALLOW_REGISTRATION = env.bool('ACCOUNT_ALLOW_REGISTRATION', True) +ACCOUNT_ALLOW_REGISTRATION = env.bool("ACCOUNT_ALLOW_REGISTRATION", True) # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_AUTHENTICATION_METHOD = 'username' +ACCOUNT_AUTHENTICATION_METHOD = "username" # https://django-allauth.readthedocs.io/en/latest/configuration.html ACCOUNT_EMAIL_REQUIRED = True # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_EMAIL_VERIFICATION = 'mandatory' +ACCOUNT_EMAIL_VERIFICATION = "mandatory" # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_ADAPTER = 'bootcamp.users.adapters.AccountAdapter' +ACCOUNT_ADAPTER = "bootcamp.users.adapters.AccountAdapter" # https://django-allauth.readthedocs.io/en/latest/configuration.html -SOCIALACCOUNT_ADAPTER = 'bootcamp.users.adapters.SocialAccountAdapter' +SOCIALACCOUNT_ADAPTER = "bootcamp.users.adapters.SocialAccountAdapter" # Your stuff... @@ -260,18 +248,14 @@ REDIS_URL = f'{env("REDIS_URL", default="redis://127.0.0.1:6379")}/{0}' # django-channels setup -ASGI_APPLICATION = 'config.routing.application' +ASGI_APPLICATION = "config.routing.application" CHANNEL_LAYERS = { - 'default': { - 'BACKEND': 'channels_redis.core.RedisChannelLayer', - 'CONFIG': { - 'hosts': [REDIS_URL, ], - }, + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": {"hosts": [REDIS_URL]}, } } # GraphQL settings -GRAPHENE = { - 'SCHEMA': 'config.schema.schema' -} +GRAPHENE = {"SCHEMA": "config.schema.schema"} diff --git a/config/settings/local.py b/config/settings/local.py index c269aeb68..c29246d1f 100755 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -8,65 +8,65 @@ # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = env.bool('DEBUG', default=True) +DEBUG = env.bool("DEBUG", default=True) # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key -SECRET_KEY = env('SECRET_KEY', default='fOqtAorZrVqWYbuMPOcZnTzw2D5bKeHGpXUwCaNBnvFUmO1njCQZGz05x1BhDG0E') +SECRET_KEY = env( + "SECRET_KEY", + default="fOqtAorZrVqWYbuMPOcZnTzw2D5bKeHGpXUwCaNBnvFUmO1njCQZGz05x1BhDG0E", +) # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = [ - "localhost", - "0.0.0.0", - "127.0.0.1", -] +ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] # CACHES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#caches CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': '' + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "", } } # TEMPLATES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#templates -TEMPLATES[0]['OPTIONS']['debug'] = DEBUG # noqa F405 +TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG # noqa F405 # EMAIL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend -EMAIL_BACKEND = env('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend') +EMAIL_BACKEND = env( + "EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend" +) # https://docs.djangoproject.com/en/dev/ref/settings/#email-host -EMAIL_HOST = 'localhost' +EMAIL_HOST = "localhost" # https://docs.djangoproject.com/en/dev/ref/settings/#email-port EMAIL_PORT = 1025 # django-debug-toolbar # ------------------------------------------------------------------------------ # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites -INSTALLED_APPS += ['debug_toolbar'] # noqa F405 +INSTALLED_APPS += ["debug_toolbar"] # noqa F405 # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware -MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] # noqa F405 +MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa F405 # https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config DEBUG_TOOLBAR_CONFIG = { - 'DISABLE_PANELS': [ - 'debug_toolbar.panels.redirects.RedirectsPanel', - ], - 'SHOW_TEMPLATE_CONTEXT': True, + "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], + "SHOW_TEMPLATE_CONTEXT": True, } # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips -INTERNAL_IPS = ['127.0.0.1', '10.0.2.2'] +INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] import socket import os -if os.environ.get('USE_DOCKER') == 'yes': + +if os.environ.get("USE_DOCKER") == "yes": hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) - INTERNAL_IPS += [ip[:-1] + '1' for ip in ips] + INTERNAL_IPS += [ip[:-1] + "1" for ip in ips] # django-extensions # ------------------------------------------------------------------------------ # https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration -INSTALLED_APPS += ['django_extensions'] # noqa F405 +INSTALLED_APPS += ["django_extensions"] # noqa F405 # Your stuff... # ------------------------------------------------------------------------------ diff --git a/config/settings/production.py b/config/settings/production.py index fed6663bb..659eb800f 100755 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -6,37 +6,39 @@ # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key -SECRET_KEY = env('SECRET_KEY') +SECRET_KEY = env("SECRET_KEY") # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['vitor@freitas.com trybootcamp.vitorfs.com']) +ALLOWED_HOSTS = env.list( + "ALLOWED_HOSTS", default=["vitor@freitas.com trybootcamp.vitorfs.com"] +) # DATABASES # ------------------------------------------------------------------------------ -DATABASES['default'] = env.db('DATABASE_URL') # noqa F405 -DATABASES['default']['ATOMIC_REQUESTS'] = True # noqa F405 -DATABASES['default']['CONN_MAX_AGE'] = env.int('CONN_MAX_AGE', default=60) # noqa F405 +DATABASES["default"] = env.db("DATABASE_URL") # noqa F405 +DATABASES["default"]["ATOMIC_REQUESTS"] = True # noqa F405 +DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) # noqa F405 # CACHES # ------------------------------------------------------------------------------ CACHES = { - 'default': { - 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': REDIS_URL, - 'OPTIONS': { - 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", # Mimicing memcache behavior. # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior - 'IGNORE_EXCEPTIONS': True, - } + "IGNORE_EXCEPTIONS": True, + }, } } # SECURITY # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect -SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=True) +SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=True) # https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure SESSION_COOKIE_SECURE = True # https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly @@ -50,26 +52,28 @@ # TODO: set this to 60 seconds first and then to 518400 once you prove the former works SECURE_HSTS_SECONDS = 60 # https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-include-subdomains -SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool('SECURE_HSTS_INCLUDE_SUBDOMAINS', default=True) +SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool( + "SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True +) # https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-preload -SECURE_HSTS_PRELOAD = env.bool('SECURE_HSTS_PRELOAD', default=True) +SECURE_HSTS_PRELOAD = env.bool("SECURE_HSTS_PRELOAD", default=True) # https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff -SECURE_CONTENT_TYPE_NOSNIFF = env.bool('SECURE_CONTENT_TYPE_NOSNIFF', default=True) +SECURE_CONTENT_TYPE_NOSNIFF = env.bool("SECURE_CONTENT_TYPE_NOSNIFF", default=True) # https://docs.djangoproject.com/en/dev/ref/settings/#secure-browser-xss-filter SECURE_BROWSER_XSS_FILTER = True # https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options -X_FRAME_OPTIONS = 'DENY' +X_FRAME_OPTIONS = "DENY" # STORAGES # ------------------------------------------------------------------------------ # https://django-storages.readthedocs.io/en/latest/#installation -INSTALLED_APPS += ['storages'] # noqa F405 +INSTALLED_APPS += ["storages"] # noqa F405 # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID') +AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID") # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY') +AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY") # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME') +AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings AWS_AUTO_CREATE_BUCKET = True # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings @@ -78,127 +82,124 @@ _AWS_EXPIRY = 60 * 60 * 24 * 7 # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings AWS_S3_OBJECT_PARAMETERS = { - 'CacheControl': f'max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate', + "CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate" } # STATIC # ------------------------ -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" # MEDIA # ------------------------------------------------------------------------------ -DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' -MEDIA_URL = f'https://s3.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}/' +DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" +MEDIA_URL = f"https://s3.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}/" # TEMPLATES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#templates -TEMPLATES[0]['OPTIONS']['loaders'] = [ # noqa F405 +TEMPLATES[0]["OPTIONS"]["loaders"] = [ # noqa F405 ( - 'django.template.loaders.cached.Loader', + "django.template.loaders.cached.Loader", [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - ] - ), + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + ) ] # EMAIL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email DEFAULT_FROM_EMAIL = env( - 'DEFAULT_FROM_EMAIL', - default='Bootcamp ' + "DEFAULT_FROM_EMAIL", + default="Bootcamp ", ) # https://docs.djangoproject.com/en/dev/ref/settings/#server-email -SERVER_EMAIL = env('SERVER_EMAIL', default=DEFAULT_FROM_EMAIL) +SERVER_EMAIL = env("SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) # https://docs.djangoproject.com/en/dev/ref/settings/#email-subject-prefix -EMAIL_SUBJECT_PREFIX = env('EMAIL_SUBJECT_PREFIX', default='[Bootcamp]') +EMAIL_SUBJECT_PREFIX = env("EMAIL_SUBJECT_PREFIX", default="[Bootcamp]") # ADMIN # ------------------------------------------------------------------------------ # Django Admin URL regex. -ADMIN_URL = env('ADMIN_URL') +ADMIN_URL = env("ADMIN_URL") # Anymail (Mailgun) # ------------------------------------------------------------------------------ # https://anymail.readthedocs.io/en/stable/installation/#installing-anymail -INSTALLED_APPS += ['anymail'] # noqa F405 -EMAIL_BACKEND = 'anymail.backends.mailgun.EmailBackend' +INSTALLED_APPS += ["anymail"] # noqa F405 +EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference ANYMAIL = { - 'MAILGUN_API_KEY': env('MAILGUN_API_KEY'), - 'MAILGUN_SENDER_DOMAIN': env('MAILGUN_SENDER_DOMAIN') + "MAILGUN_API_KEY": env("MAILGUN_API_KEY"), + "MAILGUN_SENDER_DOMAIN": env("MAILGUN_SENDER_DOMAIN"), } # WhiteNoise # ------------------------------------------------------------------------------ # http://whitenoise.evans.io/en/latest/django.html#enable-whitenoise -MIDDLEWARE = ['whitenoise.middleware.WhiteNoiseMiddleware'] + MIDDLEWARE # noqa F405 +MIDDLEWARE = ["whitenoise.middleware.WhiteNoiseMiddleware"] + MIDDLEWARE # noqa F405 # raven # ------------------------------------------------------------------------------ # https://docs.sentry.io/clients/python/integrations/django/ -INSTALLED_APPS += ['raven.contrib.django.raven_compat'] # noqa F405 -MIDDLEWARE = ['raven.contrib.django.raven_compat.middleware.SentryResponseErrorIdMiddleware'] + MIDDLEWARE +INSTALLED_APPS += ["raven.contrib.django.raven_compat"] # noqa F405 +MIDDLEWARE = [ + "raven.contrib.django.raven_compat.middleware.SentryResponseErrorIdMiddleware" +] + MIDDLEWARE # Sentry # ------------------------------------------------------------------------------ -SENTRY_DSN = env('SENTRY_DSN') -SENTRY_CLIENT = env('SENTRY_CLIENT', default='raven.contrib.django.raven_compat.DjangoClient') +SENTRY_DSN = env("SENTRY_DSN") +SENTRY_CLIENT = env( + "SENTRY_CLIENT", default="raven.contrib.django.raven_compat.DjangoClient" +) LOGGING = { - 'version': 1, - 'disable_existing_loggers': True, - 'root': { - 'level': 'WARNING', - 'handlers': ['sentry'], + "version": 1, + "disable_existing_loggers": True, + "root": {"level": "WARNING", "handlers": ["sentry"]}, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s " + "%(process)d %(thread)d %(message)s" + } }, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s ' - '%(process)d %(thread)d %(message)s' + "handlers": { + "sentry": { + "level": "ERROR", + "class": "raven.contrib.django.raven_compat.handlers.SentryHandler", }, - }, - 'handlers': { - 'sentry': { - 'level': 'ERROR', - 'class': 'raven.contrib.django.raven_compat.handlers.SentryHandler', + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' - } }, - 'loggers': { - 'django.db.backends': { - 'level': 'ERROR', - 'handlers': ['console'], - 'propagate': False, - }, - 'raven': { - 'level': 'DEBUG', - 'handlers': ['console'], - 'propagate': False, + "loggers": { + "django.db.backends": { + "level": "ERROR", + "handlers": ["console"], + "propagate": False, }, - 'sentry.errors': { - 'level': 'DEBUG', - 'handlers': ['console'], - 'propagate': False, + "raven": {"level": "DEBUG", "handlers": ["console"], "propagate": False}, + "sentry.errors": { + "level": "DEBUG", + "handlers": ["console"], + "propagate": False, }, - 'django.security.DisallowedHost': { - 'level': 'ERROR', - 'handlers': ['console', 'sentry'], - 'propagate': False, + "django.security.DisallowedHost": { + "level": "ERROR", + "handlers": ["console", "sentry"], + "propagate": False, }, }, } -SENTRY_CELERY_LOGLEVEL = env.int('SENTRY_LOG_LEVEL', logging.INFO) +SENTRY_CELERY_LOGLEVEL = env.int("SENTRY_LOG_LEVEL", logging.INFO) RAVEN_CONFIG = { - 'CELERY_LOGLEVEL': env.int('SENTRY_LOG_LEVEL', logging.INFO), - 'DSN': SENTRY_DSN + "CELERY_LOGLEVEL": env.int("SENTRY_LOG_LEVEL", logging.INFO), + "DSN": SENTRY_DSN, } # Your stuff... diff --git a/config/settings/test.py b/config/settings/test.py index 0f046befa..04b077503 100755 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -10,47 +10,45 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#debug DEBUG = False # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key -SECRET_KEY = env('SECRET_KEY', default='0wG2N60LqkDwM0Vi42p63bTekW3ac7Jt9w140') +SECRET_KEY = env("SECRET_KEY", default="0wG2N60LqkDwM0Vi42p63bTekW3ac7Jt9w140") # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner -TEST_RUNNER = 'django.test.runner.DiscoverRunner' +TEST_RUNNER = "django.test.runner.DiscoverRunner" # CACHES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#caches CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': '' + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "", } } # PASSWORDS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers -PASSWORD_HASHERS = [ - 'django.contrib.auth.hashers.MD5PasswordHasher', -] +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] # TEMPLATES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#templates -TEMPLATES[0]['OPTIONS']['debug'] = DEBUG # noqa F405 -TEMPLATES[0]['OPTIONS']['loaders'] = [ # noqa F405 +TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG # noqa F405 +TEMPLATES[0]["OPTIONS"]["loaders"] = [ # noqa F405 ( - 'django.template.loaders.cached.Loader', + "django.template.loaders.cached.Loader", [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", ], - ), + ) ] # EMAIL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend -EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" # https://docs.djangoproject.com/en/dev/ref/settings/#email-host -EMAIL_HOST = 'localhost' +EMAIL_HOST = "localhost" # https://docs.djangoproject.com/en/dev/ref/settings/#email-port EMAIL_PORT = 1025 diff --git a/config/urls.py b/config/urls.py index 2debc405c..1787dfb9a 100755 --- a/config/urls.py +++ b/config/urls.py @@ -8,42 +8,54 @@ from graphene_django.views import GraphQLView urlpatterns = [ - url(r'^$', - TemplateView.as_view(template_name='pages/home.html'), name='home'), - url(r'^about/$', - TemplateView.as_view(template_name='pages/about.html'), name='about'), + url(r"^$", TemplateView.as_view(template_name="pages/home.html"), name="home"), + url( + r"^about/$", + TemplateView.as_view(template_name="pages/about.html"), + name="about", + ), # Django Admin, use {% url 'admin:index' %} url(settings.ADMIN_URL, admin.site.urls), # User management - url(r'^users/', include('bootcamp.users.urls', namespace='users')), - url(r'^accounts/', include('allauth.urls')), + url(r"^users/", include("bootcamp.users.urls", namespace="users")), + url(r"^accounts/", include("allauth.urls")), # Third party apps here - url(r'^comments/', include('django_comments.urls')), - url(r'^graphql', GraphQLView.as_view(graphiql=True)), - url(r'^markdownx/', include('markdownx.urls')), + url(r"^comments/", include("django_comments.urls")), + url(r"^graphql", GraphQLView.as_view(graphiql=True)), + url(r"^markdownx/", include("markdownx.urls")), # Local apps here - url(r'^notifications/', - include('bootcamp.notifications.urls', namespace='notifications')), - url(r'^articles/', - include('bootcamp.articles.urls', namespace='articles')), - url(r'^news/', include('bootcamp.news.urls', namespace='news')), - url(r'^messages/', - include('bootcamp.messager.urls', namespace='messager')), - url(r'^qa/', include('bootcamp.qa.urls', namespace='qa')), - url(r'^search/', include('bootcamp.search.urls', namespace='search')), - + url( + r"^notifications/", + include("bootcamp.notifications.urls", namespace="notifications"), + ), + url(r"^articles/", include("bootcamp.articles.urls", namespace="articles")), + url(r"^news/", include("bootcamp.news.urls", namespace="news")), + url(r"^messages/", include("bootcamp.messager.urls", namespace="messager")), + url(r"^qa/", include("bootcamp.qa.urls", namespace="qa")), + url(r"^search/", include("bootcamp.search.urls", namespace="search")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: # This allows the error pages to be debugged during development urlpatterns += [ - url(r'^400/$', default_views.bad_request, kwargs={'exception': Exception('Bad Request!')}), - url(r'^403/$', default_views.permission_denied, kwargs={'exception': Exception('Permission Denied')}), - url(r'^404/$', default_views.page_not_found, kwargs={'exception': Exception('Page not Found')}), - url(r'^500/$', default_views.server_error), + url( + r"^400/$", + default_views.bad_request, + kwargs={"exception": Exception("Bad Request!")}, + ), + url( + r"^403/$", + default_views.permission_denied, + kwargs={"exception": Exception("Permission Denied")}, + ), + url( + r"^404/$", + default_views.page_not_found, + kwargs={"exception": Exception("Page not Found")}, + ), + url(r"^500/$", default_views.server_error), ] - if 'debug_toolbar' in settings.INSTALLED_APPS: + if "debug_toolbar" in settings.INSTALLED_APPS: import debug_toolbar - urlpatterns = [ - url(r'^__debug__/', include(debug_toolbar.urls)), - ] + urlpatterns + + urlpatterns = [url(r"^__debug__/", include(debug_toolbar.urls))] + urlpatterns diff --git a/config/wsgi.py b/config/wsgi.py index ac31de273..b0edf189e 100755 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -20,11 +20,12 @@ # This allows easy placement of apps within the interior # bootcamp directory. -app_path = os.path.abspath(os.path.join( - os.path.dirname(os.path.abspath(__file__)), os.pardir)) -sys.path.append(os.path.join(app_path, 'bootcamp')) +app_path = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) +) +sys.path.append(os.path.join(app_path, "bootcamp")) -if os.environ.get('DJANGO_SETTINGS_MODULE') == 'config.settings.production': +if os.environ.get("DJANGO_SETTINGS_MODULE") == "config.settings.production": from raven.contrib.django.raven_compat.middleware.wsgi import Sentry # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks @@ -37,7 +38,7 @@ # file. This includes Django's development server, if the WSGI_APPLICATION # setting points here. application = get_wsgi_application() -if os.environ.get('DJANGO_SETTINGS_MODULE') == 'config.settings.production': +if os.environ.get("DJANGO_SETTINGS_MODULE") == "config.settings.production": application = Sentry(application) # Apply WSGI middleware here. # from helloworld.wsgi import HelloWorldApplication diff --git a/manage.py b/manage.py index 06ab82d75..4bc215c33 100755 --- a/manage.py +++ b/manage.py @@ -1,8 +1,8 @@ import os import sys -if __name__ == '__main__': - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.local') +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") try: from django.core.management import execute_from_command_line @@ -25,6 +25,6 @@ # This allows easy placement of apps within the inner bootcamp directory. current_path = os.path.dirname(os.path.abspath(__file__)) - sys.path.append(os.path.join(current_path, 'bootcamp')) + sys.path.append(os.path.join(current_path, "bootcamp")) execute_from_command_line(sys.argv) From 265f9d06ff3401e826a0c5ef73860817292c7c72 Mon Sep 17 00:00:00 2001 From: Matej Bobrik Date: Mon, 11 Nov 2019 00:55:32 +0100 Subject: [PATCH 16/50] Repaired merge faults and add some tests --- bootcamp/articles/tests/test_models.py | 12 ++++++ bootcamp/articles/tests/test_views.py | 24 +++++++++++ bootcamp/qa/tests/test_models.py | 8 +++- bootcamp/static/js/qa.js | 60 +++++++------------------- bootcamp/templates/base.html | 5 +-- 5 files changed, 60 insertions(+), 49 deletions(-) diff --git a/bootcamp/articles/tests/test_models.py b/bootcamp/articles/tests/test_models.py index e8e4fe7f0..2620f6aa6 100755 --- a/bootcamp/articles/tests/test_models.py +++ b/bootcamp/articles/tests/test_models.py @@ -13,6 +13,7 @@ def setUp(self): status="P", user=self.user, ) + self.article.tags.add("test1", "test2") self.not_p_article = Article.objects.create( title="A really nice to-be title", content="""This is a really good content, just if somebody @@ -22,6 +23,7 @@ def setUp(self): everybody always wants the real deal.""", user=self.user, ) + self.not_p_article.tags.add("test1", "test2") def test_object_instance(self): assert isinstance(self.article, Article) @@ -36,3 +38,13 @@ def test_return_values(self): assert self.article in Article.objects.get_published() assert Article.objects.get_published()[0].title == "A really nice title" assert self.not_p_article in Article.objects.get_drafts() + + def test_get_popular_tags(self): + correct_dict = {"test1": 1, "test2": 1} + assert Article.objects.get_counted_tags() == correct_dict.items() + + def test_change_draft_title(self): + assert self.not_p_article.title == "A really nice to-be title" + self.not_p_article.title = "A really nice changed title" + self.not_p_article.save() + assert self.not_p_article.title == "A really nice changed title" diff --git a/bootcamp/articles/tests/test_views.py b/bootcamp/articles/tests/test_views.py index 464110a1f..bc27d6b20 100755 --- a/bootcamp/articles/tests/test_views.py +++ b/bootcamp/articles/tests/test_views.py @@ -111,3 +111,27 @@ def test_draft_article(self): resp.context["articles"][0].slug == "first-user-a-not-that-really-nice-title" ) + + @override_settings(MEDIA_ROOT=tempfile.gettempdir()) + def test_draft_article_change(self): + response = self.client.post( + reverse("articles:edit_article", kwargs={"pk": self.not_p_article.id}), + { + "title": "A really nice changed title", + "content": "Whatever works for you", + "tags": "list, lists", + "status": "D", + "image": self.test_image, + }, + ) + resp = self.client.get(reverse("articles:drafts")) + assert resp.status_code == 200 + assert response.status_code == 302 + assert ( + resp.context["articles"][0].title + == "A really nice changed title" + ) + assert ( + resp.context["articles"][0].slug + == "first-user-a-really-nice-changed-title" + ) diff --git a/bootcamp/qa/tests/test_models.py b/bootcamp/qa/tests/test_models.py index 58cfd649f..5f8567788 100755 --- a/bootcamp/qa/tests/test_models.py +++ b/bootcamp/qa/tests/test_models.py @@ -11,8 +11,8 @@ def setUp(self): user=self.user, title="This is a sample question", content="This is a sample question content", - tags="test1, test2", ) + self.question_one.tags.add("test1", "test2") self.question_two = Question.objects.create( user=self.user, title="A Short Title", @@ -22,8 +22,8 @@ def setUp(self): know than nobody wants to publish a test, just a test; everybody always wants the real deal.""", has_answer=True, - tags="test1, test2", ) + self.question_two.tags.add("test1", "test2") self.answer = Answer.objects.create( user=self.user, question=self.question_two, @@ -88,6 +88,10 @@ def test_question_answer_count(self): def test_question_accepted_answer(self): assert self.question_two.get_accepted_answer() == self.answer + def test_get_popular_tags(self): + correct_dict = {"test1": 2, "test2": 2} + assert Question.objects.get_counted_tags() == correct_dict.items() + # Answer model tests def test_answer_return_value(self): assert str(self.answer) == "A reaaaaally loooong content" diff --git a/bootcamp/static/js/qa.js b/bootcamp/static/js/qa.js index ced881e3d..95c66f84a 100755 --- a/bootcamp/static/js/qa.js +++ b/bootcamp/static/js/qa.js @@ -65,30 +65,16 @@ $(function () { type: 'post', cache: false, success: function (data) { - $('.vote', span).removeClass('voted'); if (vote === "U") { - $(span).addClass('voted'); + $('#questionUpVote').addClass('voted'); + $('#questionDownVote').removeClass('voted'); + } else { + $('#questionDownVote').addClass('voted'); + $('#questionUpVote').removeClass('voted'); } - $.ajax({ - url: '/qa/question/vote/', - data: { - 'question': question, - 'value': vote - }, - type: 'post', - cache: false, - success: function (data) { - if (vote === "U") { - $('#questionUpVote').addClass('voted'); - $('#questionDownVote').removeClass('voted'); - } else { - $('#questionDownVote').addClass('voted'); - $('#questionUpVote').removeClass('voted'); - } - $("#questionVotes").text(data.votes); - } - }); - }); + $("#questionVotes").text(data.votes); + } + }); }); $(".answer-vote").click(function () { @@ -110,30 +96,16 @@ $(function () { type: 'post', cache: false, success: function (data) { - $('.vote', span).removeClass('voted'); if (vote === "U") { - $(span).addClass('voted'); + $('#answerUpVote').addClass('voted'); + $('#answerDownVote').removeClass('voted'); + } else { + $('#answerDownVote').addClass('voted'); + $('#answerUpVote').removeClass('voted'); } - $.ajax({ - url: '/qa/answer/vote/', - data: { - 'answer': answer, - 'value': vote - }, - type: 'post', - cache: false, - success: function (data) { - if (vote === "U") { - $('#answerUpVote').addClass('voted'); - $('#answerDownVote').removeClass('voted'); - } else { - $('#answerDownVote').addClass('voted'); - $('#answerUpVote').removeClass('voted'); - } - $("#answerVotes").text(data.votes); - } - }); - }); + $("#answerVotes").text(data.votes); + } + }); }); $("#acceptAnswer").click(function () { diff --git a/bootcamp/templates/base.html b/bootcamp/templates/base.html index 4d6b14c80..e88caaabf 100755 --- a/bootcamp/templates/base.html +++ b/bootcamp/templates/base.html @@ -113,9 +113,8 @@ {% endblock javascript %} From 5b2c1497f850093259137e02b6fb7e528d5e8f73 Mon Sep 17 00:00:00 2001 From: Vamoss Date: Tue, 19 Nov 2019 14:54:01 -0200 Subject: [PATCH 17/50] Fix always popping notifications in translated sites --- bootcamp/static/js/bootcamp.js | 2 +- bootcamp/templates/notifications/most_recent.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bootcamp/static/js/bootcamp.js b/bootcamp/static/js/bootcamp.js index 76dd1eaa2..016acf500 100755 --- a/bootcamp/static/js/bootcamp.js +++ b/bootcamp/static/js/bootcamp.js @@ -22,7 +22,7 @@ $('.form-group').removeClass('row'); /* Notifications JS basic client */ $(function () { - let emptyMessage = 'You have no unread notification'; + let emptyMessage = 'data-empty="true"'; function checkNotifications() { $.ajax({ diff --git a/bootcamp/templates/notifications/most_recent.html b/bootcamp/templates/notifications/most_recent.html index 849e74358..42eda4196 100755 --- a/bootcamp/templates/notifications/most_recent.html +++ b/bootcamp/templates/notifications/most_recent.html @@ -15,6 +15,6 @@ {% trans 'See all' %} {% trans 'Mark all as read' %} {% else %} -
  • {% trans 'You have no unread notification' %}
  • +
  • {% trans 'You have no unread notification' %}
  • {% endif %} From 34589698d1e8d66b65012014b16e16ee45d5663c Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Fri, 29 Nov 2019 08:42:11 -0500 Subject: [PATCH 18/50] Fixed broken test, ignored untestable lines, and replaced an abandoned project with one alive with the same functionality. --- bootcamp/articles/models.py | 4 ++-- bootcamp/articles/tests/test_views.py | 2 +- bootcamp/notifications/models.py | 2 +- bootcamp/qa/models.py | 2 +- requirements/base.txt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bootcamp/articles/models.py b/bootcamp/articles/models.py index a88c1885f..44fdb22d9 100755 --- a/bootcamp/articles/models.py +++ b/bootcamp/articles/models.py @@ -75,7 +75,7 @@ def __str__(self): def save(self, *args, **kwargs): if not self.slug: self.slug = slugify( - f"{self.user.username}-{self.title}", to_lower=True, max_length=80 + f"{self.user.username}-{self.title}", lowercase=True, max_length=80 ) super().save(*args, **kwargs) @@ -84,7 +84,7 @@ def get_markdown(self): return markdownify(self.content) -def notify_comment(**kwargs): +def notify_comment(**kwargs): # pragma: no cover """Handler to be fired up upon comments signal to notify the author of a given article.""" actor = kwargs["request"].user diff --git a/bootcamp/articles/tests/test_views.py b/bootcamp/articles/tests/test_views.py index bc27d6b20..4d506932c 100755 --- a/bootcamp/articles/tests/test_views.py +++ b/bootcamp/articles/tests/test_views.py @@ -133,5 +133,5 @@ def test_draft_article_change(self): ) assert ( resp.context["articles"][0].slug - == "first-user-a-really-nice-changed-title" + == "first-user-a-really-nice-to-be-title" ) diff --git a/bootcamp/notifications/models.py b/bootcamp/notifications/models.py index ee3985b02..062486e9d 100755 --- a/bootcamp/notifications/models.py +++ b/bootcamp/notifications/models.py @@ -154,7 +154,7 @@ def save(self, *args, **kwargs): if not self.slug: self.slug = slugify( f"{self.recipient} {self.uuid_id} {self.verb}", - to_lower=True, + lowercase=True, max_length=200, ) diff --git a/bootcamp/qa/models.py b/bootcamp/qa/models.py index 849f1e568..0be1b4844 100755 --- a/bootcamp/qa/models.py +++ b/bootcamp/qa/models.py @@ -94,7 +94,7 @@ class Meta: def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(f"{self.title}-{self.id}", to_lower=True, max_length=80) + self.slug = slugify(f"{self.title}-{self.id}", lowercase=True, max_length=80) super().save(*args, **kwargs) diff --git a/requirements/base.txt b/requirements/base.txt index 87cd54575..b18b11f36 100755 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,7 +1,7 @@ # Python # ------------------------------------------------------------------------------ argon2-cffi # https://github.com/hynek/argon2_cffi -awesome-slugify # https://github.com/dimka665/awesome-slugify +python-slugify # https://github.com/un33k/python-slugify Pillow # https://github.com/python-pillow/Pillow pytz # https://github.com/stub42/pytz redis # https://github.com/antirez/redis From 05161db6085ed86fa4e59e450d2c1acb0b5ff81f Mon Sep 17 00:00:00 2001 From: Jonathan Omarwoth Date: Mon, 25 Nov 2019 18:57:37 +0300 Subject: [PATCH 19/50] Fix owner voting their question or answer --- bootcamp/qa/views.py | 60 ++++++++++++++-------- bootcamp/static/css/qa.css | 2 +- bootcamp/static/js/qa.js | 39 +++++++------- bootcamp/templates/qa/answer_sample.html | 4 +- bootcamp/templates/qa/question_detail.html | 6 +-- bootcamp/utils/__init__.py | 0 bootcamp/utils/app_utils.py | 12 +++++ bootcamp/utils/qa_utils.py | 10 ++++ 8 files changed, 87 insertions(+), 46 deletions(-) create mode 100644 bootcamp/utils/__init__.py create mode 100644 bootcamp/utils/app_utils.py create mode 100644 bootcamp/utils/qa_utils.py diff --git a/bootcamp/qa/views.py b/bootcamp/qa/views.py index e42d5da25..22044a438 100755 --- a/bootcamp/qa/views.py +++ b/bootcamp/qa/views.py @@ -11,6 +11,8 @@ from bootcamp.helpers import ajax_required from bootcamp.qa.models import Question, Answer from bootcamp.qa.forms import QuestionForm +from bootcamp.utils.app_utils import is_owner +from bootcamp.utils.qa_utils import update_votes class QuestionsIndexListView(LoginRequiredMixin, ListView): @@ -56,6 +58,16 @@ class QuestionDetailView(LoginRequiredMixin, DetailView): model = Question context_object_name = "question" + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + question = self.get_object() + if self.request.user.username == question.user.username: + is_question_owner = True + else: + is_question_owner = False + context['is_question_owner'] = is_question_owner + return context + class CreateQuestionView(LoginRequiredMixin, CreateView): """ @@ -100,19 +112,23 @@ def question_vote(request): """Function view to receive AJAX call, returns the count of votes a given question has recieved.""" question_id = request.POST["question"] - value = None - if request.POST["value"] == "U": - value = True + question = Question.objects.get(pk=question_id) - else: - value = False + is_question_owner = is_owner(question, request.user.username) + if is_question_owner: + return JsonResponse({ + "message": "You can't vote your own question.", + "is_owner": is_question_owner + }) + + value = True if request.POST["value"] == "U" else False - question = Question.objects.get(pk=question_id) try: - question.votes.update_or_create( - user=request.user, defaults={"value": value}, ) - question.count_votes() - return JsonResponse({"votes": question.total_votes}) + update_votes(question, request.user, value) + return JsonResponse({ + "votes": question.total_votes, + "is_owner": is_question_owner + }) except IntegrityError: # pragma: no cover return JsonResponse({'status': 'false', @@ -127,19 +143,23 @@ def answer_vote(request): """Function view to receive AJAX call, returns the count of votes a given answer has recieved.""" answer_id = request.POST["answer"] - value = None - if request.POST["value"] == "U": - value = True + answer = Answer.objects.get(uuid_id=answer_id) - else: - value = False + is_answer_owner = is_owner(answer, request.user.username) + if is_answer_owner: + return JsonResponse({ + "message": "You can't vote your own answer.", + "is_owner": is_answer_owner + }) + + value = True if request.POST["value"] == "U" else False - answer = Answer.objects.get(uuid_id=answer_id) try: - answer.votes.update_or_create( - user=request.user, defaults={"value": value}, ) - answer.count_votes() - return JsonResponse({"votes": answer.total_votes}) + update_votes(answer, request.user, value) + return JsonResponse({ + "votes": answer.total_votes, + "is_owner": is_answer_owner + }) except IntegrityError: # pragma: no cover return JsonResponse({'status': 'false', diff --git a/bootcamp/static/css/qa.css b/bootcamp/static/css/qa.css index 4eea5245b..947cf54a0 100755 --- a/bootcamp/static/css/qa.css +++ b/bootcamp/static/css/qa.css @@ -46,7 +46,7 @@ margin-top: .4em; } -.options i.vote { +.options i.vote, .options i.is-owner { font-size: 2em; color: #ddd; } diff --git a/bootcamp/static/js/qa.js b/bootcamp/static/js/qa.js index 6b5ca7493..0a4496608 100755 --- a/bootcamp/static/js/qa.js +++ b/bootcamp/static/js/qa.js @@ -46,11 +46,25 @@ $(function () { $("#question-form").submit(); }); + function toogleVote(voteIcon, vote, data, isAnswer) { + var idPrefix = isAnswer ? 'answer' : 'question'; + var isOwner = data.is_owner; + if (isOwner === false) { + if (vote === "U") { + voteIcon.addClass('voted'); + voteIcon.siblings(`#${idPrefix}DownVote`).removeClass('voted'); + } else { + voteIcon.addClass('voted'); + voteIcon.siblings(`#${idPrefix}UpVote`).removeClass('voted'); + } + voteIcon.siblings(`#${idPrefix}Votes`).text(data.votes); + } + } + $(".question-vote").click(function () { // Vote on a question. - var span = $(this); + var voteIcon = $(this); var question = $(this).closest(".question").attr("question-id"); - vote = null; if ($(this).hasClass("up-vote")) { vote = "U"; } else { @@ -65,23 +79,15 @@ $(function () { type: 'post', cache: false, success: function (data) { - if (vote === "U") { - $('#questionUpVote').addClass('voted'); - $('#questionDownVote').removeClass('voted'); - } else { - $('#questionDownVote').addClass('voted'); - $('#questionUpVote').removeClass('voted'); - } - $("#questionVotes").text(data.votes); + toogleVote(voteIcon, vote, data, false); } }); }); $(".answer-vote").click(function () { // Vote on an answer. - var span = $(this); + var voteIcon = $(this); var answer = $(this).closest(".answer").attr("answer-id"); - vote = null; if ($(this).hasClass("up-vote")) { vote = "U"; } else { @@ -96,14 +102,7 @@ $(function () { type: 'post', cache: false, success: function (data) { - if (vote === "U") { - $('#answerUpVote').addClass('voted'); - $('#answerDownVote').removeClass('voted'); - } else { - $('#answerDownVote').addClass('voted'); - $('#answerUpVote').removeClass('voted'); - } - $("#answerVotes").text(data.votes); + toogleVote(voteIcon, vote, data, true); } }); }); diff --git a/bootcamp/templates/qa/answer_sample.html b/bootcamp/templates/qa/answer_sample.html index d4450779e..fe974750b 100755 --- a/bootcamp/templates/qa/answer_sample.html +++ b/bootcamp/templates/qa/answer_sample.html @@ -3,9 +3,9 @@
    {% csrf_token %}
    - + {{ answer.total_votes }} - + {% if answer.is_answer and user == question.user %} {% elif answer.is_answer %} diff --git a/bootcamp/templates/qa/question_detail.html b/bootcamp/templates/qa/question_detail.html index b5761287f..aa9106198 100755 --- a/bootcamp/templates/qa/question_detail.html +++ b/bootcamp/templates/qa/question_detail.html @@ -27,9 +27,9 @@

    {{ question.title }}

    {{ question.count_answers }}

    {% trans 'Answers' %} - +

    {{ question.total_votes }}

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

    {% trans 'Answers' %}

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

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

      diff --git a/bootcamp/utils/__init__.py b/bootcamp/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bootcamp/utils/app_utils.py b/bootcamp/utils/app_utils.py new file mode 100644 index 000000000..0857a0133 --- /dev/null +++ b/bootcamp/utils/app_utils.py @@ -0,0 +1,12 @@ +def is_owner(obj, username): + """ + Checks if model instance belongs to a user + Args: + obj: A model instance + username(str): User's username + Returns: + boolean: True is model instance belongs to user else False + """ + if obj.user.username == username: + return True + return False diff --git a/bootcamp/utils/qa_utils.py b/bootcamp/utils/qa_utils.py new file mode 100644 index 000000000..e90f3f278 --- /dev/null +++ b/bootcamp/utils/qa_utils.py @@ -0,0 +1,10 @@ +def update_votes(obj, user, value): + """ + Updates votes for either a question or answer + Args: + obj: Question or Answer model instance + user: User model instance voting an anwser or question + value(str): 'U' for an up vote or 'D' for down vote + """ + obj.votes.update_or_create(user=user, defaults={"value": value}, ) + obj.count_votes() From 685d1e50a8c9d69d6ed019001c95dbe8fa8d6b31 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 5 Jan 2020 08:36:25 -0500 Subject: [PATCH 20/50] Bumping Django versions. --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index f18f5ced8..182c391de 100755 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,8 @@ language: python python: - "3.6" env: - - DJANGO_VERSION=2.0.7 - - DJANGO_VERSION=1.11.14 + - DJANGO_VERSION=2.2.9 + - DJANGO_VERSION=3.0.3 branches: only: - master From c384530c8445c62f09658b9ce11b087957ce195c Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 5 Jan 2020 08:42:51 -0500 Subject: [PATCH 21/50] Bumping Django versions, again because Travis just has an old one. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 182c391de..52e36de83 100755 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ python: - "3.6" env: - DJANGO_VERSION=2.2.9 - - DJANGO_VERSION=3.0.3 + - DJANGO_VERSION=3.0 branches: only: - master From 50e763c89c0ddd30f58d814a5e4abb4e1f681a07 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 5 Jan 2020 09:55:32 -0500 Subject: [PATCH 22/50] Changing Python and Django versions to fix compatibilities. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 52e36de83..99415d638 100755 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ language: python python: - "3.6" + - "3.7" env: - DJANGO_VERSION=2.2.9 - - DJANGO_VERSION=3.0 branches: only: - master From b19e1375be33e5f70aa0f41fe1d92bb196903b43 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 5 Jan 2020 10:46:49 -0500 Subject: [PATCH 23/50] Fixed unnecessary file structure. --- bootcamp/helpers.py | 28 ++++++++++++++++++++++++++++ bootcamp/qa/views.py | 4 ++-- bootcamp/utils/__init__.py | 0 bootcamp/utils/app_utils.py | 12 ------------ bootcamp/utils/qa_utils.py | 10 ---------- 5 files changed, 30 insertions(+), 24 deletions(-) delete mode 100644 bootcamp/utils/__init__.py delete mode 100644 bootcamp/utils/app_utils.py delete mode 100644 bootcamp/utils/qa_utils.py diff --git a/bootcamp/helpers.py b/bootcamp/helpers.py index 098bd4d3a..34005cabe 100755 --- a/bootcamp/helpers.py +++ b/bootcamp/helpers.py @@ -51,3 +51,31 @@ def dispatch(self, request, *args, **kwargs): raise PermissionDenied return super().dispatch(request, *args, **kwargs) + + +def is_owner(obj, username): + """ + Checks if model instance belongs to a user + Args: + obj: A model instance + username(str): User's username + Returns: + boolean: True is model instance belongs to user else False + """ + if obj.user.username == username: + return True + return False + + +def update_votes(obj, user, value): + """ + Updates votes for either a question or answer + Args: + obj: Question or Answer model instance + user: User model instance voting an anwser or question + value(str): 'U' for an up vote or 'D' for down vote + """ + obj.votes.update_or_create( + user=user, defaults={"value": value}, + ) + obj.count_votes() diff --git a/bootcamp/qa/views.py b/bootcamp/qa/views.py index f6777d947..5039c09f1 100755 --- a/bootcamp/qa/views.py +++ b/bootcamp/qa/views.py @@ -11,8 +11,8 @@ from bootcamp.helpers import ajax_required from bootcamp.qa.models import Question, Answer from bootcamp.qa.forms import QuestionForm -from bootcamp.utils.app_utils import is_owner -from bootcamp.utils.qa_utils import update_votes +from bootcamp.helpers import is_owner +from bootcamp.helpers import update_votes class QuestionsIndexListView(LoginRequiredMixin, ListView): diff --git a/bootcamp/utils/__init__.py b/bootcamp/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bootcamp/utils/app_utils.py b/bootcamp/utils/app_utils.py deleted file mode 100644 index 0857a0133..000000000 --- a/bootcamp/utils/app_utils.py +++ /dev/null @@ -1,12 +0,0 @@ -def is_owner(obj, username): - """ - Checks if model instance belongs to a user - Args: - obj: A model instance - username(str): User's username - Returns: - boolean: True is model instance belongs to user else False - """ - if obj.user.username == username: - return True - return False diff --git a/bootcamp/utils/qa_utils.py b/bootcamp/utils/qa_utils.py deleted file mode 100644 index e90f3f278..000000000 --- a/bootcamp/utils/qa_utils.py +++ /dev/null @@ -1,10 +0,0 @@ -def update_votes(obj, user, value): - """ - Updates votes for either a question or answer - Args: - obj: Question or Answer model instance - user: User model instance voting an anwser or question - value(str): 'U' for an up vote or 'D' for down vote - """ - obj.votes.update_or_create(user=user, defaults={"value": value}, ) - obj.count_votes() From ded6c8f03db5d5d93db474ea64bc2415d2c57d24 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 5 Jan 2020 10:48:06 -0500 Subject: [PATCH 24/50] Let black run free. --- bootcamp/articles/tests/test_views.py | 8 ++------ bootcamp/qa/models.py | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/bootcamp/articles/tests/test_views.py b/bootcamp/articles/tests/test_views.py index 4d506932c..69005dc75 100755 --- a/bootcamp/articles/tests/test_views.py +++ b/bootcamp/articles/tests/test_views.py @@ -127,11 +127,7 @@ def test_draft_article_change(self): resp = self.client.get(reverse("articles:drafts")) assert resp.status_code == 200 assert response.status_code == 302 + assert resp.context["articles"][0].title == "A really nice changed title" assert ( - resp.context["articles"][0].title - == "A really nice changed title" - ) - assert ( - resp.context["articles"][0].slug - == "first-user-a-really-nice-to-be-title" + resp.context["articles"][0].slug == "first-user-a-really-nice-to-be-title" ) diff --git a/bootcamp/qa/models.py b/bootcamp/qa/models.py index 0be1b4844..a59b89f4e 100755 --- a/bootcamp/qa/models.py +++ b/bootcamp/qa/models.py @@ -94,7 +94,9 @@ class Meta: def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(f"{self.title}-{self.id}", lowercase=True, max_length=80) + self.slug = slugify( + f"{self.title}-{self.id}", lowercase=True, max_length=80 + ) super().save(*args, **kwargs) From 361c0952e3025a04e91549abfaa61719260b8c73 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 5 Jan 2020 11:39:47 -0500 Subject: [PATCH 25/50] Fixed some styling issues and added tests to check the basics. --- bootcamp/qa/tests/test_views.py | 12 ++++++++++++ bootcamp/qa/views.py | 2 -- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/bootcamp/qa/tests/test_views.py b/bootcamp/qa/tests/test_views.py index 1ede6b79d..9e6e889d3 100755 --- a/bootcamp/qa/tests/test_views.py +++ b/bootcamp/qa/tests/test_views.py @@ -117,3 +117,15 @@ def test_accept_answer(self): HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) assert response_one.status_code == 200 + + def test_owner_in_context(self): + response_one = self.client.get( + reverse("qa:question_detail", kwargs={"pk": self.question_one.id}) + ) + response_two = self.other_client.get( + reverse("qa:question_detail", kwargs={"pk": self.question_two.id}) + ) + assert response_one.status_code == 200 + assert response_two.status_code == 200 + assert response_one.context.get("is_question_owner") is True + assert response_two.context.get("is_question_owner") is False diff --git a/bootcamp/qa/views.py b/bootcamp/qa/views.py index 5039c09f1..75236e940 100755 --- a/bootcamp/qa/views.py +++ b/bootcamp/qa/views.py @@ -118,7 +118,6 @@ def question_vote(request): question has recieved.""" question_id = request.POST["question"] question = Question.objects.get(pk=question_id) - is_question_owner = is_owner(question, request.user.username) if is_question_owner: return JsonResponse( @@ -150,7 +149,6 @@ def answer_vote(request): answer has recieved.""" answer_id = request.POST["answer"] answer = Answer.objects.get(uuid_id=answer_id) - is_answer_owner = is_owner(answer, request.user.username) if is_answer_owner: return JsonResponse( From 7428d0a7e829d922dae5450952472d8c73dca24e Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 5 Jan 2020 15:29:44 -0500 Subject: [PATCH 26/50] Updating documentation and improving coverage. --- README.rst | 2 +- bootcamp/notifications/models.py | 19 +--- bootcamp/notifications/tests/test_models.py | 100 +++++++++++++++++++- 3 files changed, 98 insertions(+), 23 deletions(-) diff --git a/README.rst b/README.rst index ba988a169..0aefc68c8 100755 --- a/README.rst +++ b/README.rst @@ -34,7 +34,7 @@ Technology Stack ---------------- * Python_ 3.6.x / 3.7.x -* `Django Web Framework`_ 2.0.x +* `Django Web Framework`_ 2.2.x * PostgreSQL_ * `Redis 5.0`_ * Daphne_ diff --git a/bootcamp/notifications/models.py b/bootcamp/notifications/models.py index 062486e9d..76229cc34 100755 --- a/bootcamp/notifications/models.py +++ b/bootcamp/notifications/models.py @@ -4,7 +4,6 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.core import serializers from django.db import models from django.utils.translation import ugettext_lazy as _ @@ -46,23 +45,9 @@ def mark_all_as_unread(self, recipient=None): return qs.update(unread=True) - def serialize_latest_notifications(self, recipient=None): - """Returns a serialized version of the most recent unread elements in - the queryset""" - qs = self.unread()[:5] - if recipient: - qs = qs.filter(recipient=recipient)[:5] - - notification_dic = serializers.serialize("json", qs) - return notification_dic - - def get_most_recent(self, recipient=None): + def get_most_recent(self): """Returns the most recent unread elements in the queryset""" - qs = self.unread()[:5] - if recipient: - qs = qs.filter(recipient=recipient)[:5] - - return qs + return self.unread()[:5] class Notification(models.Model): diff --git a/bootcamp/notifications/tests/test_models.py b/bootcamp/notifications/tests/test_models.py index 850644310..57d914148 100755 --- a/bootcamp/notifications/tests/test_models.py +++ b/bootcamp/notifications/tests/test_models.py @@ -8,6 +8,15 @@ class NotificationsModelsTest(TestCase): def setUp(self): self.user = self.make_user("test_user") self.other_user = self.make_user("other_test_user") + self.first_news = News.objects.create( + user=self.user, content="This is a short content." + ) + self.second_news = News.objects.create( + user=self.other_user, + content="This is an answer to the first news.", + reply=True, + parent=self.first_news, + ) self.first_notification = Notification.objects.create( actor=self.user, recipient=self.other_user, verb="L" ) @@ -17,17 +26,28 @@ def setUp(self): self.third_notification = Notification.objects.create( actor=self.other_user, recipient=self.user, verb="A" ) + self.fourth_notification = Notification.objects.create( + actor=self.other_user, + recipient=self.user, + action_object=self.first_news, + verb="A", + ) def test_return_values(self): assert isinstance(self.first_notification, Notification) assert isinstance(self.second_notification, Notification) assert isinstance(self.third_notification, Notification) + assert isinstance(self.fourth_notification, Notification) assert str(self.first_notification) == "test_user liked 0 minutes ago" assert str(self.second_notification) == "test_user commented 0 minutes ago" assert str(self.third_notification) == "other_test_user answered 0 minutes ago" + assert ( + str(self.fourth_notification) + == "other_test_user answered This is a short content. 0 minutes ago" + ) def test_return_unread(self): - assert Notification.objects.unread().count() == 3 + assert Notification.objects.unread().count() == 4 assert self.first_notification in Notification.objects.unread() def test_mark_as_read_and_return(self): @@ -39,16 +59,16 @@ def test_mark_as_read_and_return(self): def test_mark_all_as_read(self): Notification.objects.mark_all_as_read() - assert Notification.objects.read().count() == 3 + assert Notification.objects.read().count() == 4 Notification.objects.mark_all_as_unread(self.other_user) - assert Notification.objects.read().count() == 1 + assert Notification.objects.read().count() == 2 Notification.objects.mark_all_as_unread() - assert Notification.objects.unread().count() == 3 + assert Notification.objects.unread().count() == 4 Notification.objects.mark_all_as_read(self.other_user) assert Notification.objects.read().count() == 2 def test_get_most_recent(self): - assert Notification.objects.get_most_recent().count() == 3 + assert Notification.objects.get_most_recent().count() == 4 def test_single_notification(self): Notification.objects.mark_all_as_read() @@ -65,3 +85,73 @@ def test_list_notification(self): Notification.objects.mark_all_as_read() notification_handler(self.user, [self.user, self.other_user], "C") assert Notification.objects.unread().count() == 2 + + def test_icon_comment(self): + notification_one = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="C" + ) + notification_two = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="A" + ) + notification_three = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="K" + ) + assert notification_one.get_icon() == "fa-comment" + assert notification_two.get_icon() == "fa-comment" + assert notification_three.get_icon() == "fa-comment" + + def test_icon_users(self): + notification_one = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="I" + ) + notification_two = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="U" + ) + notification_three = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="O" + ) + assert notification_one.get_icon() == "fa-users" + assert notification_two.get_icon() == "fa-users" + assert notification_three.get_icon() == "fa-users" + + def test_icon_hearth(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="L" + ) + assert notification.get_icon() == "fa-heart" + + def test_icon_star(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="F" + ) + assert notification.get_icon() == "fa-star" + + def test_icon_check_circle(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="W" + ) + assert notification.get_icon() == "fa-check-circle" + + def test_icon_pencil(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="E" + ) + assert notification.get_icon() == "fa-pencil" + + def test_icon_plus(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="V" + ) + assert notification.get_icon() == "fa-plus" + + def test_icon_share(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="S" + ) + assert notification.get_icon() == "fa-share-alt" + + def test_icon_reply(self): + notification = Notification.objects.create( + actor=self.user, recipient=self.other_user, verb="R" + ) + assert notification.get_icon() == "fa-reply" From 5d720036b7d12b0a240d616a5014993337d6d928 Mon Sep 17 00:00:00 2001 From: leon Date: Thu, 26 Mar 2020 10:36:34 +0000 Subject: [PATCH 27/50] fix race condition bug in messages --- bootcamp/customers/__init__.py | 1 + bootcamp/messager/models.py | 11 +++++++---- bootcamp/messager/views.py | 7 ++++++- 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 bootcamp/customers/__init__.py diff --git a/bootcamp/customers/__init__.py b/bootcamp/customers/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/bootcamp/customers/__init__.py @@ -0,0 +1 @@ + diff --git a/bootcamp/messager/models.py b/bootcamp/messager/models.py index 65263ab0b..9e6d1bdba 100755 --- a/bootcamp/messager/models.py +++ b/bootcamp/messager/models.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.db import transaction from asgiref.sync import async_to_sync @@ -94,9 +95,11 @@ def send_message(sender, recipient, message): payload = { "type": "receive", "key": "message", - "message_id": new_message.uuid_id, - "sender": sender, - "recipient": recipient, + "message_id": str(new_message.uuid_id), + "sender": str(sender), + "recipient": str(recipient), } - async_to_sync(channel_layer.group_send)(recipient.username, payload) + transaction.on_commit( + lambda: async_to_sync(channel_layer.group_send)(recipient.username, + payload)) return new_message diff --git a/bootcamp/messager/views.py b/bootcamp/messager/views.py index 744776efe..a07210ae0 100755 --- a/bootcamp/messager/views.py +++ b/bootcamp/messager/views.py @@ -6,6 +6,7 @@ from django.views.decorators.http import require_http_methods from django.views.generic import ListView + from bootcamp.messager.models import Message from bootcamp.helpers import ajax_required @@ -80,5 +81,9 @@ def receive_message(request): """Simple AJAX functional view to return a rendered single message on the receiver side providing realtime connections.""" message_id = request.GET.get("message_id") - message = Message.objects.get(pk=message_id) + try: + message = Message.objects.get(pk=message_id) + except Message.DoesNotExist as e: + raise e + return render(request, "messager/single_message.html", {"message": message}) From eca727d99651ae226bab4e0155b45c5e7b52e492 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Fri, 27 Mar 2020 07:17:42 -0500 Subject: [PATCH 28/50] Removing customers folder. --- bootcamp/customers/__init__.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 bootcamp/customers/__init__.py diff --git a/bootcamp/customers/__init__.py b/bootcamp/customers/__init__.py deleted file mode 100644 index 8b1378917..000000000 --- a/bootcamp/customers/__init__.py +++ /dev/null @@ -1 +0,0 @@ - From a8589fa7acad678d3175e4921eef7b16bff576f7 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Tue, 31 Mar 2020 16:31:33 -0500 Subject: [PATCH 29/50] Adding environment tests. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 99415d638..5142c38dd 100755 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: - "3.7" env: - DJANGO_VERSION=2.2.9 + - DJANGO_VERSION=3.0.3 branches: only: - master From 6241ad04ba38cc6a1240b382ed297e70aad292cf Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Tue, 31 Mar 2020 16:39:47 -0500 Subject: [PATCH 30/50] More blackening to the code. --- bootcamp/messager/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bootcamp/messager/models.py b/bootcamp/messager/models.py index 9e6d1bdba..b8645a5b1 100755 --- a/bootcamp/messager/models.py +++ b/bootcamp/messager/models.py @@ -100,6 +100,6 @@ def send_message(sender, recipient, message): "recipient": str(recipient), } transaction.on_commit( - lambda: async_to_sync(channel_layer.group_send)(recipient.username, - payload)) + lambda: async_to_sync(channel_layer.group_send)(recipient.username, payload) + ) return new_message From 5ef8173b1b78b6ba59e3ff2b38abdb333e66a55b Mon Sep 17 00:00:00 2001 From: vamoss Date: Sun, 5 Apr 2020 10:00:36 -0300 Subject: [PATCH 31/50] add news url metatag auto extractor --- bootcamp/news/metadatareader.py | 125 ++++++++++++++++++ .../migrations/0002_auto_20200405_1227.py | 38 ++++++ bootcamp/news/models.py | 14 ++ bootcamp/news/templatetags/__init__.py | 0 .../news/templatetags/urlize_target_blank.py | 11 ++ bootcamp/static/css/news.css | 52 ++++++-- bootcamp/templates/news/news_single.html | 51 ++++--- bootcamp/templates/news/news_thread.html | 44 ++++-- requirements/base.txt | 4 + 9 files changed, 297 insertions(+), 42 deletions(-) create mode 100755 bootcamp/news/metadatareader.py create mode 100644 bootcamp/news/migrations/0002_auto_20200405_1227.py create mode 100755 bootcamp/news/templatetags/__init__.py create mode 100755 bootcamp/news/templatetags/urlize_target_blank.py diff --git a/bootcamp/news/metadatareader.py b/bootcamp/news/metadatareader.py new file mode 100755 index 000000000..d0e08f013 --- /dev/null +++ b/bootcamp/news/metadatareader.py @@ -0,0 +1,125 @@ +import re +import subprocess +from subprocess import TimeoutExpired +from bs4 import BeautifulSoup, Comment +from urllib.parse import urljoin + +class Metadata: + url = "" + type = "" # https://ogp.me/#types + title = "" + description = "" + image = "" + + def __str__(self): + return "{url: " + self.url + ", type: " + self.type + ", title: " + self.title + ", description: " + self.description + ", image: " + self.image + "}" + +class Metadatareader: + + @staticmethod + def get_metadata_from_url_in_text(text): + # look for the first url in the text + # and extract the url metadata + urls_in_text = Metadatareader.get_urls_from_text(text) + if len(urls_in_text) > 0: + return Metadatareader.get_url_metadata(urls_in_text[0]) + return Metadata() + + @staticmethod + def get_urls_from_text(text): + # look for all urls in text + # and convert it to an array of urls + regex = r"(?i)\b((?:https?:(?:\/{1,3}|[a-z0-9%])|[a-z0-9.\-]+[.](?:com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|Ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)\/)(?:[^\s()<>{}\[\]]+|\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\))+(?:\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\)|[^\s`!()\[\]{};:\'\".,<>?«»“”‘’])|(?:(? 0: + metadata.image = urljoin(url, images[0].get("src")) + + if not metadata.description and soup.body: + # use text from body + for text in soup.body.find_all(string=True): + if text.parent.name != "script" and text.parent.name != "style" and not isinstance(text, Comment): + metadata.description += text + + if metadata.description: + # remove white spaces and break lines + metadata.description = re.sub("\n|\r|\t", " ", metadata.description) + metadata.description = re.sub(" +", " ", metadata.description) + metadata.description = metadata.description.strip() + + return metadata + + @staticmethod + def get_final_url(url, timeout=5): + # get final url after all redirections + # get http response header + # look for the "Location: " header + proc = subprocess.Popen([ + "curl", + "-Ls",#follow redirect 301 and silently + "-I",#don't download html body + url + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + out, err = proc.communicate(timeout=timeout) + except TimeoutExpired: + proc.kill() + out, err = proc.communicate() + header = str(out).split("\\r\\n") + for line in header: + if line.startswith("Location: "): + return line.replace("Location: ", "") + return url + + @staticmethod + def get_url_content(url, timeout=5): + # get url html + proc = subprocess.Popen([ + "curl", + "-i", + "-k",#ignore ssl certificate requisite + "-L",#follow redirect 301 + url + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + out, err = proc.communicate(timeout=timeout) + except TimeoutExpired: + proc.kill() + out, err = proc.communicate() + return out + + @staticmethod + def get_meta_property(meta, property_name, default_value=""): + if "property" in meta.attrs and meta.attrs["property"] == property_name: + return meta.attrs["content"] + return default_value \ No newline at end of file diff --git a/bootcamp/news/migrations/0002_auto_20200405_1227.py b/bootcamp/news/migrations/0002_auto_20200405_1227.py new file mode 100644 index 000000000..5830da0b6 --- /dev/null +++ b/bootcamp/news/migrations/0002_auto_20200405_1227.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.3 on 2020-04-05 12:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='news', + name='meta_description', + field=models.TextField(max_length=255, null=True), + ), + migrations.AddField( + model_name='news', + name='meta_image', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='news', + name='meta_title', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='news', + name='meta_type', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='news', + name='meta_url', + field=models.CharField(max_length=2048, null=True), + ), + ] diff --git a/bootcamp/news/models.py b/bootcamp/news/models.py index b45cbfe51..ef282f8c4 100755 --- a/bootcamp/news/models.py +++ b/bootcamp/news/models.py @@ -10,6 +10,7 @@ from channels.layers import get_channel_layer from bootcamp.notifications.models import Notification, notification_handler +from bootcamp.news.metadatareader import Metadata, Metadatareader class News(models.Model): @@ -32,6 +33,11 @@ class News(models.Model): settings.AUTH_USER_MODEL, blank=True, related_name="liked_news" ) reply = models.BooleanField(verbose_name=_("Is a reply?"), default=False) + meta_url = models.CharField(max_length=2048, null=True) + meta_type = models.CharField(max_length=255, null=True) + meta_title = models.CharField(max_length=255, null=True) + meta_description = models.TextField(max_length=255, null=True) + meta_image = models.CharField(max_length=255, null=True) class Meta: verbose_name = _("News") @@ -42,6 +48,14 @@ def __str__(self): return str(self.content) def save(self, *args, **kwargs): + #extract metada from content url + metadata = Metadatareader.get_metadata_from_url_in_text(self.content) + self.meta_url = metadata.url[0:2048] + self.meta_type = metadata.type[0:255] + self.meta_title = metadata.title[0:255] + self.meta_description = metadata.description[0:255] + self.meta_image = metadata.image[0:255] + super().save(*args, **kwargs) if not self.reply: channel_layer = get_channel_layer() diff --git a/bootcamp/news/templatetags/__init__.py b/bootcamp/news/templatetags/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/bootcamp/news/templatetags/urlize_target_blank.py b/bootcamp/news/templatetags/urlize_target_blank.py new file mode 100755 index 000000000..80e05c18b --- /dev/null +++ b/bootcamp/news/templatetags/urlize_target_blank.py @@ -0,0 +1,11 @@ +from django import template +from django.template.defaultfilters import stringfilter +from django.utils.safestring import mark_safe +from django.utils.html import urlize as urlize_impl + +register = template.Library() + +@register.filter(is_safe=True, needs_autoescape=True) +@stringfilter +def urlize_target_blank(value, autoescape=None): + return value.replace('
      - diff --git a/bootcamp/templates/news/news_thread.html b/bootcamp/templates/news/news_thread.html index e64f539e5..827cacdc8 100755 --- a/bootcamp/templates/news/news_thread.html +++ b/bootcamp/templates/news/news_thread.html @@ -1,6 +1,7 @@ {% load i18n %} {% load humanize static %} {% load thumbnail %} +{% load urlize_target_blank %} {% for reply in thread %} @@ -24,22 +25,37 @@ {{ reply.user.get_profile_name|title }}

      -

      {{ reply }}

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

      {{ reply|urlize|urlize_target_blank }}

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

      {{ reply.meta_description }}

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

      {{ reply.meta_url }}

      + {% endif %} +
      +
      {% endif %} +
    - {% endfor %} diff --git a/requirements/base.txt b/requirements/base.txt index b18b11f36..a106ca342 100755 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -27,3 +27,7 @@ channels-redis # https://github.com/django/channels_redis # GraphQL API # ------------------------------------------------------------------------------ graphene-django # https://github.com/graphql-python/graphene-django + +# Open Graph Protocol +# ------------------------------------------------------------------------------ +beautifulsoup4 # https://www.crummy.com/software/BeautifulSoup/ From 633eb1f9ee0af99ae0dcee7efe73e340cd308dae Mon Sep 17 00:00:00 2001 From: vamoss Date: Tue, 7 Apr 2020 04:03:08 -0300 Subject: [PATCH 32/50] on the news, allow user to delete his own comment --- bootcamp/news/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bootcamp/news/views.py b/bootcamp/news/views.py index e849362cc..96cef53e8 100755 --- a/bootcamp/news/views.py +++ b/bootcamp/news/views.py @@ -74,7 +74,10 @@ def get_thread(request): news = News.objects.get(pk=news_id) news_html = render_to_string("news/news_single.html", {"news": news}) thread_html = render_to_string( - "news/news_thread.html", {"thread": news.get_thread()} + "news/news_thread.html", { + "thread": news.get_thread(), + "request": request + } ) return JsonResponse({"uuid": news_id, "news": news_html, "thread": thread_html}) From 638b38c6da6f188a683aaca8e679e0d0e56def6a Mon Sep 17 00:00:00 2001 From: vamoss Date: Tue, 7 Apr 2020 04:23:06 -0300 Subject: [PATCH 33/50] on articles, remove unnecessary comment fields --- bootcamp/templates/articles/article_detail.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bootcamp/templates/articles/article_detail.html b/bootcamp/templates/articles/article_detail.html index c0a39c30f..a4a801074 100755 --- a/bootcamp/templates/articles/article_detail.html +++ b/bootcamp/templates/articles/article_detail.html @@ -47,7 +47,12 @@
    {% trans 'Leave a Comment' %}:
    {% get_comment_form for article as form %}
    {% csrf_token %} - {{ form|crispy }} + {{ form.comment|as_crispy_field }} + {{ form.honeyspot }} + {{ form.content_type }} + {{ form.object_pk }} + {{ form.timestamp }} + {{ form.security_hash }}
    From 22efa27e09b69a1e395edb216f49ea16072493e8 Mon Sep 17 00:00:00 2001 From: vamoss Date: Tue, 7 Apr 2020 04:31:33 -0300 Subject: [PATCH 34/50] on articles, fix comment line break --- bootcamp/templates/articles/article_detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootcamp/templates/articles/article_detail.html b/bootcamp/templates/articles/article_detail.html index a4a801074..d5348719c 100755 --- a/bootcamp/templates/articles/article_detail.html +++ b/bootcamp/templates/articles/article_detail.html @@ -72,7 +72,7 @@
    {% trans 'Leave a Comment' %}:
    {% endthumbnail %}
    {{ comment.user.get_profile_name|title }}
    - {{ comment }} + {{ comment.comment|linebreaks }}
    {% endfor %} From f32b9d2454fdb29c0703b4e8711c483b3ffae849 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Wed, 8 Apr 2020 10:08:59 -0500 Subject: [PATCH 35/50] Adding missing requirements, why where them missing? Don't know, my horrible mistake. --- requirements/local.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements/local.txt b/requirements/local.txt index 2d0ef0b5a..bd322ceb2 100755 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -18,6 +18,8 @@ bandit # https://github.com/PyCQA/bandit pylint-common # https://github.com/landscapeio/pylint-common pylint-django # https://github.com/PyCQA/pylint-django pylint # https://github.com/PyCQA/pylint +black # https://github.com/psf/black +flake8 # https://github.com/pycqa/flake8 # Django # ------------------------------------------------------------------------------ From a1de7a441012095b7e8db3467fa5935da905c668 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sat, 11 Apr 2020 11:40:41 -0500 Subject: [PATCH 36/50] Applying nice styles. --- bootcamp/news/metadatareader.py | 81 +++++++++++++------ .../migrations/0002_auto_20200405_1227.py | 22 ++--- bootcamp/news/models.py | 4 +- .../news/templatetags/urlize_target_blank.py | 3 +- bootcamp/news/views.py | 5 +- 5 files changed, 73 insertions(+), 42 deletions(-) diff --git a/bootcamp/news/metadatareader.py b/bootcamp/news/metadatareader.py index d0e08f013..a18c69b0a 100755 --- a/bootcamp/news/metadatareader.py +++ b/bootcamp/news/metadatareader.py @@ -4,18 +4,31 @@ from bs4 import BeautifulSoup, Comment from urllib.parse import urljoin + class Metadata: url = "" - type = "" # https://ogp.me/#types + type = "" # https://ogp.me/#types title = "" description = "" image = "" def __str__(self): - return "{url: " + self.url + ", type: " + self.type + ", title: " + self.title + ", description: " + self.description + ", image: " + self.image + "}" + return ( + "{url: " + + self.url + + ", type: " + + self.type + + ", title: " + + self.title + + ", description: " + + self.description + + ", image: " + + self.image + + "}" + ) -class Metadatareader: +class Metadatareader: @staticmethod def get_metadata_from_url_in_text(text): # look for the first url in the text @@ -48,13 +61,21 @@ def get_url_metadata(url): for meta in soup.findAll("meta"): # priorize using Open Graph Protocol # https://ogp.me/ - metadata.type = Metadatareader.get_meta_property(meta, "og:type", metadata.type) - metadata.title = Metadatareader.get_meta_property(meta, "og:title", metadata.title) - metadata.description = Metadatareader.get_meta_property(meta, "og:description", metadata.description) - metadata.image = Metadatareader.get_meta_property(meta, "og:image", metadata.image) + metadata.type = Metadatareader.get_meta_property( + meta, "og:type", metadata.type + ) + metadata.title = Metadatareader.get_meta_property( + meta, "og:title", metadata.title + ) + metadata.description = Metadatareader.get_meta_property( + meta, "og:description", metadata.description + ) + metadata.image = Metadatareader.get_meta_property( + meta, "og:image", metadata.image + ) if metadata.image: metadata.image = urljoin(url, metadata.image) - + if not metadata.title and soup.title: # use page title metadata.title = soup.title.text @@ -68,7 +89,11 @@ def get_url_metadata(url): if not metadata.description and soup.body: # use text from body for text in soup.body.find_all(string=True): - if text.parent.name != "script" and text.parent.name != "style" and not isinstance(text, Comment): + if ( + text.parent.name != "script" + and text.parent.name != "style" + and not isinstance(text, Comment) + ): metadata.description += text if metadata.description: @@ -76,7 +101,7 @@ def get_url_metadata(url): metadata.description = re.sub("\n|\r|\t", " ", metadata.description) metadata.description = re.sub(" +", " ", metadata.description) metadata.description = metadata.description.strip() - + return metadata @staticmethod @@ -84,12 +109,16 @@ def get_final_url(url, timeout=5): # get final url after all redirections # get http response header # look for the "Location: " header - proc = subprocess.Popen([ - "curl", - "-Ls",#follow redirect 301 and silently - "-I",#don't download html body - url - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc = subprocess.Popen( + [ + "curl", + "-Ls", # follow redirect 301 and silently + "-I", # don't download html body + url, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) try: out, err = proc.communicate(timeout=timeout) except TimeoutExpired: @@ -104,13 +133,17 @@ def get_final_url(url, timeout=5): @staticmethod def get_url_content(url, timeout=5): # get url html - proc = subprocess.Popen([ - "curl", - "-i", - "-k",#ignore ssl certificate requisite - "-L",#follow redirect 301 - url - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc = subprocess.Popen( + [ + "curl", + "-i", + "-k", # ignore ssl certificate requisite + "-L", # follow redirect 301 + url, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) try: out, err = proc.communicate(timeout=timeout) except TimeoutExpired: @@ -122,4 +155,4 @@ def get_url_content(url, timeout=5): def get_meta_property(meta, property_name, default_value=""): if "property" in meta.attrs and meta.attrs["property"] == property_name: return meta.attrs["content"] - return default_value \ No newline at end of file + return default_value diff --git a/bootcamp/news/migrations/0002_auto_20200405_1227.py b/bootcamp/news/migrations/0002_auto_20200405_1227.py index 5830da0b6..9cb20b084 100644 --- a/bootcamp/news/migrations/0002_auto_20200405_1227.py +++ b/bootcamp/news/migrations/0002_auto_20200405_1227.py @@ -6,33 +6,33 @@ class Migration(migrations.Migration): dependencies = [ - ('news', '0001_initial'), + ("news", "0001_initial"), ] operations = [ migrations.AddField( - model_name='news', - name='meta_description', + model_name="news", + name="meta_description", field=models.TextField(max_length=255, null=True), ), migrations.AddField( - model_name='news', - name='meta_image', + model_name="news", + name="meta_image", field=models.CharField(max_length=255, null=True), ), migrations.AddField( - model_name='news', - name='meta_title', + model_name="news", + name="meta_title", field=models.CharField(max_length=255, null=True), ), migrations.AddField( - model_name='news', - name='meta_type', + model_name="news", + name="meta_type", field=models.CharField(max_length=255, null=True), ), migrations.AddField( - model_name='news', - name='meta_url', + model_name="news", + name="meta_url", field=models.CharField(max_length=2048, null=True), ), ] diff --git a/bootcamp/news/models.py b/bootcamp/news/models.py index ef282f8c4..467ad64c1 100755 --- a/bootcamp/news/models.py +++ b/bootcamp/news/models.py @@ -48,14 +48,14 @@ def __str__(self): return str(self.content) def save(self, *args, **kwargs): - #extract metada from content url + # extract metada from content url metadata = Metadatareader.get_metadata_from_url_in_text(self.content) self.meta_url = metadata.url[0:2048] self.meta_type = metadata.type[0:255] self.meta_title = metadata.title[0:255] self.meta_description = metadata.description[0:255] self.meta_image = metadata.image[0:255] - + super().save(*args, **kwargs) if not self.reply: channel_layer = get_channel_layer() diff --git a/bootcamp/news/templatetags/urlize_target_blank.py b/bootcamp/news/templatetags/urlize_target_blank.py index 80e05c18b..5463a845f 100755 --- a/bootcamp/news/templatetags/urlize_target_blank.py +++ b/bootcamp/news/templatetags/urlize_target_blank.py @@ -5,7 +5,8 @@ register = template.Library() + @register.filter(is_safe=True, needs_autoescape=True) @stringfilter def urlize_target_blank(value, autoescape=None): - return value.replace(' Date: Sat, 11 Apr 2020 11:44:37 -0500 Subject: [PATCH 37/50] Removing a far too common exception in pylint report. --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 6df81d26f..8fce85910 100755 --- a/setup.cfg +++ b/setup.cfg @@ -58,7 +58,7 @@ confidence= # either give multiple identifier separated by comma (,) or put this option # multiple time. See also the "--disable" option for examples. disable=all -enable=import-error,import-self,reimported,wildcard-import,misplaced-future,deprecated-module,unpacking-non-sequence,invalid-all-object,undefined-all-variable,used-before-assignment,cell-var-from-loop,global-variable-undefined,redefine-in-handler,unused-import,unused-wildcard-import,global-variable-not-assigned,undefined-loop-variable,global-statement,global-at-module-level,bad-open-mode,redundant-unittest-assert,boolean-datetimedeprecated-method,anomalous-unicode-escape-in-string,anomalous-backslash-in-string,not-in-loop,continue-in-finally,abstract-class-instantiated,star-needs-assignment-target,duplicate-argument-name,return-in-init,too-many-star-expressions,nonlocal-and-global,return-outside-function,return-arg-in-generator,invalid-star-assignment-target,bad-reversed-sequence,nonexistent-operator,yield-outside-function,init-is-generator,nonlocal-without-binding,lost-exception,assert-on-tuple,dangerous-default-value,duplicate-key,useless-else-on-loopexpression-not-assigned,confusing-with-statement,unnecessary-lambda,pointless-statement,pointless-string-statement,unnecessary-pass,unreachable,eval-used,exec-used,using-constant-test,bad-super-call,missing-super-argument,slots-on-old-class,super-on-old-class,property-on-old-class,not-an-iterable,not-a-mapping,format-needs-mapping,truncated-format-string,missing-format-string-key,mixed-format-string,too-few-format-args,bad-str-strip-call,too-many-format-args,bad-format-character,format-combined-specification,bad-format-string-key,bad-format-string,missing-format-attribute,missing-format-argument-key,unused-format-string-argumentunused-format-string-key,invalid-format-index,bad-indentation,mixed-indentation,unnecessary-semicolon,lowercase-l-suffix,invalid-encoded-data,unpacking-in-except,import-star-module-level,long-suffix,old-octal-literal,old-ne-operator,backtick,old-raise-syntax,metaclass-assignment,next-method-called,dict-iter-method,dict-view-method,indexing-exception,raising-string,using-cmp-argument,cmp-method,coerce-method,delslice-method,getslice-method,hex-method,nonzero-method,t-method,setslice-method,old-division,logging-format-truncated,logging-too-few-args,logging-too-many-args,logging-unsupported-format,logging-format-interpolation,invalid-unary-operand-type,unsupported-binary-operation,not-callable,redundant-keyword-arg,assignment-from-no-return,assignment-from-none,not-context-manager,repeated-keyword,missing-kwoa,no-value-for-parameter,invalid-sequence-index,invalid-slice-index,unexpected-keyword-arg,unsupported-membership-test,unsubscriptable-object,access-member-before-definition,method-hidden,assigning-non-slot,duplicate-bases,inconsistent-mro,inherit-non-class,invalid-slots,invalid-slots-object,no-method-argument,no-self-argument,unexpected-special-method-signature,non-iterator-returned,arguments-differ,signature-differs,bad-staticmethod-argument,non-parent-init-called,bad-except-order,catching-non-exception,bad-exception-context,notimplemented-raised,raising-bad-type,raising-non-exception,misplaced-bare-raise,duplicate-except,broad-except,nonstandard-exception,binary-op-exception,bare-except,not-async-context-manager,yield-inside-async-function +enable=import-error,import-self,reimported,wildcard-import,misplaced-future,deprecated-module,unpacking-non-sequence,invalid-all-object,undefined-all-variable,used-before-assignment,cell-var-from-loop,global-variable-undefined,redefine-in-handler,unused-import,unused-wildcard-import,global-variable-not-assigned,undefined-loop-variable,global-statement,global-at-module-level,bad-open-mode,redundant-unittest-assert,boolean-datetimedeprecated-method,anomalous-unicode-escape-in-string,anomalous-backslash-in-string,not-in-loop,continue-in-finally,abstract-class-instantiated,star-needs-assignment-target,duplicate-argument-name,return-in-init,too-many-star-expressions,nonlocal-and-global,return-outside-function,return-arg-in-generator,invalid-star-assignment-target,bad-reversed-sequence,nonexistent-operator,yield-outside-function,init-is-generator,nonlocal-without-binding,lost-exception,assert-on-tuple,dangerous-default-value,duplicate-key,useless-else-on-loopexpression-not-assigned,confusing-with-statement,unnecessary-lambda,pointless-statement,pointless-string-statement,unnecessary-pass,unreachable,eval-used,exec-used,using-constant-test,bad-super-call,missing-super-argument,slots-on-old-class,super-on-old-class,property-on-old-class,not-an-iterable,not-a-mapping,format-needs-mapping,truncated-format-string,missing-format-string-key,mixed-format-string,too-few-format-args,bad-str-strip-call,too-many-format-args,bad-format-character,format-combined-specification,bad-format-string-key,bad-format-string,missing-format-attribute,missing-format-argument-key,unused-format-string-argumentunused-format-string-key,invalid-format-index,bad-indentation,mixed-indentation,unnecessary-semicolon,lowercase-l-suffix,invalid-encoded-data,unpacking-in-except,import-star-module-level,long-suffix,old-octal-literal,old-ne-operator,backtick,old-raise-syntax,metaclass-assignment,next-method-called,dict-iter-method,dict-view-method,indexing-exception,raising-string,using-cmp-argument,cmp-method,coerce-method,delslice-method,getslice-method,hex-method,nonzero-method,t-method,setslice-method,old-division,logging-format-truncated,logging-too-few-args,logging-too-many-args,logging-unsupported-format,logging-format-interpolation,invalid-unary-operand-type,unsupported-binary-operation,not-callable,redundant-keyword-arg,assignment-from-no-return,assignment-from-none,not-context-manager,repeated-keyword,missing-kwoa,no-value-for-parameter,invalid-sequence-index,invalid-slice-index,unexpected-keyword-arg,unsupported-membership-test,unsubscriptable-object,access-member-before-definition,method-hidden,assigning-non-slot,duplicate-bases,inconsistent-mro,inherit-non-class,invalid-slots,invalid-slots-object,no-method-argument,no-self-argument,unexpected-special-method-signature,non-iterator-returned,signature-differs,bad-staticmethod-argument,non-parent-init-called,bad-except-order,catching-non-exception,bad-exception-context,notimplemented-raised,raising-bad-type,raising-non-exception,misplaced-bare-raise,duplicate-except,broad-except,nonstandard-exception,binary-op-exception,bare-except,not-async-context-manager,yield-inside-async-function # Needs investigation: # abstract-method (might be indicating a bug? probably not though) # protected-access (requires some refactoring) From 7a6f1b71ef0e26330a0f431952ec6fccfcd281fd Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sat, 11 Apr 2020 11:54:41 -0500 Subject: [PATCH 38/50] Simplifying the URL regex. --- bootcamp/news/metadatareader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootcamp/news/metadatareader.py b/bootcamp/news/metadatareader.py index a18c69b0a..8f5d29aab 100755 --- a/bootcamp/news/metadatareader.py +++ b/bootcamp/news/metadatareader.py @@ -42,7 +42,7 @@ def get_metadata_from_url_in_text(text): def get_urls_from_text(text): # look for all urls in text # and convert it to an array of urls - regex = r"(?i)\b((?:https?:(?:\/{1,3}|[a-z0-9%])|[a-z0-9.\-]+[.](?:com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|Ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)\/)(?:[^\s()<>{}\[\]]+|\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\))+(?:\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\)|[^\s`!()\[\]{};:\'\".,<>?«»“”‘’])|(?:(? Date: Sat, 11 Apr 2020 14:04:15 -0500 Subject: [PATCH 39/50] Replacing unsafe calls for a more Pythonic approach. --- bootcamp/news/metadatareader.py | 59 +++------------------------------ 1 file changed, 5 insertions(+), 54 deletions(-) diff --git a/bootcamp/news/metadatareader.py b/bootcamp/news/metadatareader.py index 8f5d29aab..8219d5608 100755 --- a/bootcamp/news/metadatareader.py +++ b/bootcamp/news/metadatareader.py @@ -1,6 +1,5 @@ import re -import subprocess -from subprocess import TimeoutExpired +import requests from bs4 import BeautifulSoup, Comment from urllib.parse import urljoin @@ -50,14 +49,13 @@ def get_url_metadata(url): # get final url after all redirections # then get html of the final url # fill the meta data with the info available - url = Metadatareader.get_final_url(url) - url_content = Metadatareader.get_url_content(url) - soup = BeautifulSoup(url_content, "html.parser") + # url = Metadatareader.get_final_url(url) + # url_content = Metadatareader.get_url_content(url) + response = requests.get(url) + soup = BeautifulSoup(response.content, "html.parser") metadata = Metadata() - metadata.url = url metadata.type = "website" - for meta in soup.findAll("meta"): # priorize using Open Graph Protocol # https://ogp.me/ @@ -104,53 +102,6 @@ def get_url_metadata(url): return metadata - @staticmethod - def get_final_url(url, timeout=5): - # get final url after all redirections - # get http response header - # look for the "Location: " header - proc = subprocess.Popen( - [ - "curl", - "-Ls", # follow redirect 301 and silently - "-I", # don't download html body - url, - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - try: - out, err = proc.communicate(timeout=timeout) - except TimeoutExpired: - proc.kill() - out, err = proc.communicate() - header = str(out).split("\\r\\n") - for line in header: - if line.startswith("Location: "): - return line.replace("Location: ", "") - return url - - @staticmethod - def get_url_content(url, timeout=5): - # get url html - proc = subprocess.Popen( - [ - "curl", - "-i", - "-k", # ignore ssl certificate requisite - "-L", # follow redirect 301 - url, - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - try: - out, err = proc.communicate(timeout=timeout) - except TimeoutExpired: - proc.kill() - out, err = proc.communicate() - return out - @staticmethod def get_meta_property(meta, property_name, default_value=""): if "property" in meta.attrs and meta.attrs["property"] == property_name: From 008ffcc18321f07156b39329fe0d5de141183dd6 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sat, 11 Apr 2020 18:45:06 -0500 Subject: [PATCH 40/50] Removed calls to unsafe procedures through Subprocess library, cleaned and scrubed a little bit the code base. --- bootcamp/news/metadatareader.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/bootcamp/news/metadatareader.py b/bootcamp/news/metadatareader.py index 8219d5608..b36cee9ee 100755 --- a/bootcamp/news/metadatareader.py +++ b/bootcamp/news/metadatareader.py @@ -39,18 +39,28 @@ def get_metadata_from_url_in_text(text): @staticmethod def get_urls_from_text(text): - # look for all urls in text - # and convert it to an array of urls + """Method to look for all URLs in a given text, extract them and return them as a tuple of urls. + :requires: + + :param text: A valid block of text of any lenght. + + :returns: + A tuple of valid URLs extracted from the text. + """ regex = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" return re.findall(regex, text) @staticmethod def get_url_metadata(url): - # get final url after all redirections - # then get html of the final url - # fill the meta data with the info available - # url = Metadatareader.get_final_url(url) - # url_content = Metadatareader.get_url_content(url) + """This method looks for the page of a given URL, extracts the page content and parses the content with + BeautifulSoup searching for the page meta, then it returns the metadata in case there is any. + :requires: + + :param url: Any valid URL to search for. + + :returns: + Metadata information extracted from a webpage. + """ response = requests.get(url) soup = BeautifulSoup(response.content, "html.parser") metadata = Metadata() From 5d31765f4712fba1886f63c711b1eb6023d2fffa Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sat, 11 Apr 2020 18:46:33 -0500 Subject: [PATCH 41/50] Added new functions derived from the metadatareader library, cleaner and faster implementations from the original one. --- bootcamp/helpers.py | 71 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/bootcamp/helpers.py b/bootcamp/helpers.py index 34005cabe..48d86ea84 100755 --- a/bootcamp/helpers.py +++ b/bootcamp/helpers.py @@ -1,7 +1,14 @@ +import re +from urllib.parse import urljoin + from django.core.exceptions import PermissionDenied +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.http import HttpResponseBadRequest +from django.utils.translation import ugettext_lazy as _ from django.views.generic import View -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator + +import bs4 +import requests def paginate_data(qs, page_size, page, paginated_type, **kwargs): @@ -79,3 +86,65 @@ def update_votes(obj, user, value): user=user, defaults={"value": value}, ) obj.count_votes() + + +def fetch_metadata(text): + urls = get_urls(text) + return get_metadata(urls[0]) + + +def get_urls(text): + """Method to look for all URLs in a given text, extract them and return them as a tuple of urls. + :requires: + + :param text: A valid block of text of any lenght. + + :returns: + A tuple of valid URLs extracted from the text. + """ + regex = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" + return re.findall(regex, text) + + +def get_metadata(url): + """This function looks for the page of a given URL, extracts the page content and parses the content + with bs4. searching for the page meta tags giving priority to the Open Graph Protocol + https://ogp.me/, then it returns the metadata in case there is any, or tries to build one. + :requires: + + :param url: Any valid URL to search for. + + :returns: + A dictionary with metadata from a given webpage. + """ + response = requests.get(url) + soup = bs4.BeautifulSoup(response.content) + ogs = soup.html.head.find_all(property=re.compile(r"^og")) + data = { + og.get("property", _("Invalid metadata property."))[3:]: og.get( + "content", _("Metadata is not valid.") + ) + for og in ogs + } + if not data.get("title"): + data["title"] = soup.html.title.text + + if data.get("image") == _("Metadata is not valid."): + images = soup.find_all("img") + if len(images) > 0: + data["image"] = urljoin(url, images[0].get("src")) + + if data.get("description") == _("Metadata is not valid."): + for text in soup.body.find_all(string=True): + if ( + text.parent.name != "script" + and text.parent.name != "style" + and not isinstance(text, bs4.Comment) + ): + data["description"] += text + + data["description"] = re.sub("\n|\r|\t", " ", data["description"]) + data["description"] = re.sub(" +", " ", data["description"]) + data["description"] = data["description"].strip()[:255] + + return data From d6883addc5061f006f4e817a51c04a762255bea6 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sat, 11 Apr 2020 18:46:56 -0500 Subject: [PATCH 42/50] Implemented calls from the model to the new functions. --- bootcamp/news/models.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bootcamp/news/models.py b/bootcamp/news/models.py index 467ad64c1..89bb5d9ac 100755 --- a/bootcamp/news/models.py +++ b/bootcamp/news/models.py @@ -10,7 +10,7 @@ from channels.layers import get_channel_layer from bootcamp.notifications.models import Notification, notification_handler -from bootcamp.news.metadatareader import Metadata, Metadatareader +from bootcamp.helpers import fetch_metadata class News(models.Model): @@ -49,12 +49,12 @@ def __str__(self): def save(self, *args, **kwargs): # extract metada from content url - metadata = Metadatareader.get_metadata_from_url_in_text(self.content) - self.meta_url = metadata.url[0:2048] - self.meta_type = metadata.type[0:255] - self.meta_title = metadata.title[0:255] - self.meta_description = metadata.description[0:255] - self.meta_image = metadata.image[0:255] + data = fetch_metadata(self.content) + self.meta_url = data.get("url") + self.meta_type = data.get("type", "website") + self.meta_title = data.get("title") + self.meta_description = data.get("description") + self.meta_image = data.get("image") super().save(*args, **kwargs) if not self.reply: From c3aba51e132cd88e77b64b2ba632a640d175009f Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 12 Apr 2020 22:04:17 -0500 Subject: [PATCH 43/50] Adding some error handling. --- bootcamp/helpers.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/bootcamp/helpers.py b/bootcamp/helpers.py index 48d86ea84..4504f9011 100755 --- a/bootcamp/helpers.py +++ b/bootcamp/helpers.py @@ -89,8 +89,18 @@ def update_votes(obj, user, value): def fetch_metadata(text): + """Method to consolidate workflow to recover the metadata of a page of the first URL found a in + a given text block. + :requieres: + + :param text: Block of text of any lenght + """ urls = get_urls(text) - return get_metadata(urls[0]) + try: + return get_metadata(urls[0]) + + except IndexError: + return None def get_urls(text): @@ -120,21 +130,20 @@ def get_metadata(url): response = requests.get(url) soup = bs4.BeautifulSoup(response.content) ogs = soup.html.head.find_all(property=re.compile(r"^og")) - data = { - og.get("property", _("Invalid metadata property."))[3:]: og.get( - "content", _("Metadata is not valid.") - ) - for og in ogs - } + data = {og.get("property")[3:]: og.get("content") for og in ogs} + if not data.get("url"): + data["url"] = url + if not data.get("title"): data["title"] = soup.html.title.text - if data.get("image") == _("Metadata is not valid."): + if not data.get("image"): images = soup.find_all("img") if len(images) > 0: data["image"] = urljoin(url, images[0].get("src")) - if data.get("description") == _("Metadata is not valid."): + if not data.get("description"): + data["description"] = "" for text in soup.body.find_all(string=True): if ( text.parent.name != "script" From ccc888ce621a15f9a8eb148bdd2cc81665c07fa1 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Sun, 12 Apr 2020 22:05:57 -0500 Subject: [PATCH 44/50] Removing unused import. --- bootcamp/helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bootcamp/helpers.py b/bootcamp/helpers.py index 4504f9011..dfa576278 100755 --- a/bootcamp/helpers.py +++ b/bootcamp/helpers.py @@ -4,7 +4,6 @@ from django.core.exceptions import PermissionDenied from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.http import HttpResponseBadRequest -from django.utils.translation import ugettext_lazy as _ from django.views.generic import View import bs4 From f12fcfe3f9f4998edb1d41b558899fe3334e027f Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Mon, 13 Apr 2020 06:42:58 -0500 Subject: [PATCH 45/50] Removing unnecessary file. --- bootcamp/news/metadatareader.py | 119 -------------------------------- 1 file changed, 119 deletions(-) delete mode 100755 bootcamp/news/metadatareader.py diff --git a/bootcamp/news/metadatareader.py b/bootcamp/news/metadatareader.py deleted file mode 100755 index b36cee9ee..000000000 --- a/bootcamp/news/metadatareader.py +++ /dev/null @@ -1,119 +0,0 @@ -import re -import requests -from bs4 import BeautifulSoup, Comment -from urllib.parse import urljoin - - -class Metadata: - url = "" - type = "" # https://ogp.me/#types - title = "" - description = "" - image = "" - - def __str__(self): - return ( - "{url: " - + self.url - + ", type: " - + self.type - + ", title: " - + self.title - + ", description: " - + self.description - + ", image: " - + self.image - + "}" - ) - - -class Metadatareader: - @staticmethod - def get_metadata_from_url_in_text(text): - # look for the first url in the text - # and extract the url metadata - urls_in_text = Metadatareader.get_urls_from_text(text) - if len(urls_in_text) > 0: - return Metadatareader.get_url_metadata(urls_in_text[0]) - return Metadata() - - @staticmethod - def get_urls_from_text(text): - """Method to look for all URLs in a given text, extract them and return them as a tuple of urls. - :requires: - - :param text: A valid block of text of any lenght. - - :returns: - A tuple of valid URLs extracted from the text. - """ - regex = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" - return re.findall(regex, text) - - @staticmethod - def get_url_metadata(url): - """This method looks for the page of a given URL, extracts the page content and parses the content with - BeautifulSoup searching for the page meta, then it returns the metadata in case there is any. - :requires: - - :param url: Any valid URL to search for. - - :returns: - Metadata information extracted from a webpage. - """ - response = requests.get(url) - soup = BeautifulSoup(response.content, "html.parser") - metadata = Metadata() - metadata.url = url - metadata.type = "website" - for meta in soup.findAll("meta"): - # priorize using Open Graph Protocol - # https://ogp.me/ - metadata.type = Metadatareader.get_meta_property( - meta, "og:type", metadata.type - ) - metadata.title = Metadatareader.get_meta_property( - meta, "og:title", metadata.title - ) - metadata.description = Metadatareader.get_meta_property( - meta, "og:description", metadata.description - ) - metadata.image = Metadatareader.get_meta_property( - meta, "og:image", metadata.image - ) - if metadata.image: - metadata.image = urljoin(url, metadata.image) - - if not metadata.title and soup.title: - # use page title - metadata.title = soup.title.text - - if not metadata.image: - # use first img element - images = soup.find_all("img") - if len(images) > 0: - metadata.image = urljoin(url, images[0].get("src")) - - if not metadata.description and soup.body: - # use text from body - for text in soup.body.find_all(string=True): - if ( - text.parent.name != "script" - and text.parent.name != "style" - and not isinstance(text, Comment) - ): - metadata.description += text - - if metadata.description: - # remove white spaces and break lines - metadata.description = re.sub("\n|\r|\t", " ", metadata.description) - metadata.description = re.sub(" +", " ", metadata.description) - metadata.description = metadata.description.strip() - - return metadata - - @staticmethod - def get_meta_property(meta, property_name, default_value=""): - if "property" in meta.attrs and meta.attrs["property"] == property_name: - return meta.attrs["content"] - return default_value From c2a17f21ee28edd2e86dfb44a32ea8f578b1fb57 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Mon, 13 Apr 2020 09:04:32 -0500 Subject: [PATCH 46/50] Bumping up version. --- bootcamp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootcamp/__init__.py b/bootcamp/__init__.py index 055908fac..3a64db1dc 100755 --- a/bootcamp/__init__.py +++ b/bootcamp/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.0.0" +__version__ = "2.1.1" __version_info__ = tuple( [ int(num) if num.isdigit() else num From 6c470d93a675b9b8754398f7dae3432dbefd2b3b Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Tue, 14 Apr 2020 13:37:16 -0500 Subject: [PATCH 47/50] Removing the existing regex to use the previous one so the regex is able to detect any URL without the http prefix. --- bootcamp/helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bootcamp/helpers.py b/bootcamp/helpers.py index dfa576278..aebda1c21 100755 --- a/bootcamp/helpers.py +++ b/bootcamp/helpers.py @@ -111,7 +111,9 @@ def get_urls(text): :returns: A tuple of valid URLs extracted from the text. """ - regex = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" + regex = ( + regex + ) = r"(?i)\b((?:https?:(?:\/{1,3}|[a-z0-9%])|[a-z0-9.\-]+[.](?:com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|Ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)\/)(?:[^\s()<>{}\[\]]+|\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\))+(?:\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\)|[^\s`!()\[\]{};:\'\".,<>?«»“”‘’])|(?:(? Date: Tue, 14 Apr 2020 16:09:41 -0500 Subject: [PATCH 48/50] Adding exception handling to avoid TimeOut situations. --- bootcamp/helpers.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/bootcamp/helpers.py b/bootcamp/helpers.py index aebda1c21..3c83c2b48 100755 --- a/bootcamp/helpers.py +++ b/bootcamp/helpers.py @@ -4,6 +4,7 @@ from django.core.exceptions import PermissionDenied from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.http import HttpResponseBadRequest +from django.utils.translation import ugettext_lazy as _ from django.views.generic import View import bs4 @@ -29,7 +30,7 @@ def paginate_data(qs, page_size, page, paginated_type, **kwargs): has_next=page_obj.has_next(), has_prev=page_obj.has_previous(), objects=page_obj.object_list, - **kwargs + **kwargs, ) @@ -128,7 +129,17 @@ def get_metadata(url): :returns: A dictionary with metadata from a given webpage. """ - response = requests.get(url) + try: + response = requests.get(url, timeout=0.9) + response.raise_for_status() + + except requests.exceptions.Timeout as e: + raise requests.exceptions.ConnectTimeout( + _( + f"\nWe found an error trying to connect to the site {url}, please find more info here:\n\n{e}" + ) + ) + soup = bs4.BeautifulSoup(response.content) ogs = soup.html.head.find_all(property=re.compile(r"^og")) data = {og.get("property")[3:]: og.get("content") for og in ogs} From a1bb5c37e6cb25bdfd362081a13fb80175416796 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Tue, 14 Apr 2020 21:04:39 -0500 Subject: [PATCH 49/50] Small tweak to control for empty data on URL management. --- bootcamp/news/models.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bootcamp/news/models.py b/bootcamp/news/models.py index 89bb5d9ac..bccfef3bd 100755 --- a/bootcamp/news/models.py +++ b/bootcamp/news/models.py @@ -50,11 +50,12 @@ def __str__(self): def save(self, *args, **kwargs): # extract metada from content url data = fetch_metadata(self.content) - self.meta_url = data.get("url") - self.meta_type = data.get("type", "website") - self.meta_title = data.get("title") - self.meta_description = data.get("description") - self.meta_image = data.get("image") + if data: + self.meta_url = data.get("url") + self.meta_type = data.get("type", "website") + self.meta_title = data.get("title") + self.meta_description = data.get("description") + self.meta_image = data.get("image") super().save(*args, **kwargs) if not self.reply: From defaab89b361530ac24f485c721d15c15a6f31f9 Mon Sep 17 00:00:00 2001 From: Sebastian Reyes Espinosa Date: Tue, 14 Apr 2020 21:06:30 -0500 Subject: [PATCH 50/50] Modified the function calls to include exception management and some URL scrubbing to avoid exceptions on requests handling. --- bootcamp/helpers.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/bootcamp/helpers.py b/bootcamp/helpers.py index 3c83c2b48..bca72b6c2 100755 --- a/bootcamp/helpers.py +++ b/bootcamp/helpers.py @@ -1,9 +1,9 @@ import re -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse from django.core.exceptions import PermissionDenied from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.http import HttpResponseBadRequest +from django.http import HttpResponseBadRequest, JsonResponse from django.utils.translation import ugettext_lazy as _ from django.views.generic import View @@ -129,14 +129,23 @@ def get_metadata(url): :returns: A dictionary with metadata from a given webpage. """ + parsed_url = urlparse(url) + if not parsed_url.scheme: + url = f"http://{parsed_url.path}" + try: response = requests.get(url, timeout=0.9) response.raise_for_status() + except requests.exceptions.ConnectionError: + return JsonResponse( + {"message": _(f"We detected the url {url} but it appears to be invalid.")} + ) + except requests.exceptions.Timeout as e: - raise requests.exceptions.ConnectTimeout( + return JsonResponse( _( - f"\nWe found an error trying to connect to the site {url}, please find more info here:\n\n{e}" + f"We found an error trying to connect to the site {url}, please find more info here:{e}" ) )