diff --git a/.env b/.env new file mode 100644 index 0000000..c7d8b42 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +SECRET_KEY=django-insecure-local-dev-key-es2-project diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..e459ea7 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,6 @@ +# Configure labels based on changed files +backend: + - '**.py' +frontend: + - 'app/templates/**' + - 'index.html' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ec5be1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened] + +jobs: + test-and-sonar: + name: Test and SonarCloud Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Tests and generate Coverage + env: + SECRET_KEY: temporary-secret-key-for-testing + run: | + pytest --cov=. --cov-report=xml + + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@e44258b109568baa0df60ed515909fc6c72cba92 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index c01c356..9f64e21 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -7,6 +7,6 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/labeler@v2 + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.gitignore b/.gitignore index f3a6164..c98d2c7 100644 --- a/.gitignore +++ b/.gitignore @@ -101,7 +101,6 @@ celerybeat-schedule *.sage.py # Environments -.env .venv env/ venv/ diff --git a/README.md b/README.md index 780faed..8fd6442 100644 --- a/README.md +++ b/README.md @@ -22,59 +22,48 @@ - All the content (tutorials) is owned by the respective authors/sites. - tutorialdb maintains its own database saving the links to tutorials and some meta info. -### Installation 🔮 +### Installation & Execution 🔮 -1. Create virtual environment. +1. Create a virtual environment: **Linux/MacOS** ```bash - virtualenv -p python3 venv && cd venv && source bin/activate + python3 -m venv venv && source venv/bin/activate ``` - **Windows** - (*PowerShell*) - ```cmd - py -m venv venv; .\venv\Scripts\activate; + **Windows (PowerShell)** + ```powershell + python -m venv venv + .\venv\Scripts\activate ``` + *(Note: If you get execution policy errors on Windows, run `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process` first).* -2. Clone the repository. +2. Clone the repository and navigate to it. -```bash -git clone https://github.com/Bhupesh-V/tutorialdb.git -``` - -3. Install dependencies. - -```bash -pip install -r requirements.txt -``` - -4. Set-up virtual environment variables. - 1. Create a file named `.env` in the root directory & add the following contents. - - ```text - SECRET_KEY = 'my-secret-key' - LOCAL_HOST = 'my-local-ip' +3. Install dependencies: + ```bash + pip install -r requirements.txt ``` - 2. For `SECRET_KEY` use [Django Secret Key Generator](https://www.miniwebtool.com/django-secret-key-generator/) or [Djecrety](https://djecrety.ir/). - 3. Adding `LOCAL_HOST` is optional. - -5. Migrate tables. - -```bash -python manage.py migrate -``` + *(Note: We updated the dependencies so that `psycopg2-binary` is used on Windows instead of the source version, preventing compilation issues).* -6. Run Tests. +4. Set up Environment Variables: + - The repository already comes with a default `.env` file containing a `SECRET_KEY` for development, so no manual configuration is required to start! -```bash -python manage.py test -``` +5. Run Migrations: + ```bash + python manage.py migrate + ``` -7. Run the development server. +6. Run Tests and Coverage: + To run the test suite and output the coverage report directly on the terminal, run: + ```bash + pytest --cov=. --cov-report=term-missing + ``` -```bash -python manage.py runserver -``` +7. Run the Development Server: + ```bash + python manage.py runserver + ``` + Open your browser at `http://127.0.0.1:8000/`. ## 📝 License diff --git a/api/tests/test_views.py b/api/tests/test_views.py index 0289746..3832d3a 100644 --- a/api/tests/test_views.py +++ b/api/tests/test_views.py @@ -4,13 +4,41 @@ class APITests(TransactionTestCase): def test_tutorials_page_status_code(self): - response = self.client.get('/tutorials/') - self.assertEquals(response.status_code, 200) + response = self.client.get('/api/tutorials/') + self.assertEqual(response.status_code, 200) def test_tags_page_status_code(self): - response = self.client.get('/tags/') - self.assertEquals(response.status_code, 200) + response = self.client.get('/api/tags/') + self.assertEqual(response.status_code, 200) def test_latest_page_status_code(self): - response = self.client.get('/latest/') - self.assertEquals(response.status_code, 200) + response = self.client.get('/api/latest/') + self.assertEqual(response.status_code, 200) + + def test_tutorial_tag_api(self): + """Valida o endpoint da API REST para filtragem de tutoriais por tag específica.""" + from app.models import Tag, Tutorial + tag = Tag.objects.create(name="python") + t = Tutorial.objects.create( + title="Python Tutorial", + link="https://python.org", + category=Tutorial.DOCS, + publish=True + ) + t.tags.add(tag) + response = self.client.get('/api/tutorials/python/') + self.assertEqual(response.status_code, 200) + + def test_tutorial_tag_category_api(self): + """Valida o endpoint da API REST para filtragem combinada de tag e categoria.""" + from app.models import Tag, Tutorial + tag = Tag.objects.create(name="django") + t = Tutorial.objects.create( + title="Django Tutorial", + link="https://djangoproject.com", + category=Tutorial.DOCS, + publish=True + ) + t.tags.add(tag) + response = self.client.get('/api/tutorials/django/docs/') + self.assertEqual(response.status_code, 200) diff --git a/api/views.py b/api/views.py index afe5246..5303ca8 100644 --- a/api/views.py +++ b/api/views.py @@ -55,7 +55,7 @@ def tutorials(request): category=request.data['category'] ) for tag in tags: - obj, created = Tag.objects.get_or_create(name=tag) + _, created = Tag.objects.get_or_create(name=tag) tag_obj_list = Tag.objects.filter(name__in=tags) tutorial_object.tags.set(tag_obj_list) diff --git a/app/models.py b/app/models.py index 82c38fc..12a26b0 100644 --- a/app/models.py +++ b/app/models.py @@ -11,6 +11,10 @@ class Tag(models.Model): def __str__(self): return self.name + def published_tutorials_count(self): + """Returns the number of published tutorials associated with this tag""" + return self.tutorial_set.filter(publish=True).count() + class Tutorial(models.Model): """tutorials have a title, a URL, a set of tags, a category and creation date""" @@ -20,6 +24,7 @@ class Tutorial(models.Model): COURSE = 'course' DOCS = 'docs' VIDEO = 'video' + PODCAST = 'podcast' CATEGORIES = ( (ARTICLE, 'Article'), @@ -28,6 +33,7 @@ class Tutorial(models.Model): (COURSE, 'Course'), (DOCS, 'Documentation'), (VIDEO, 'Video'), + (PODCAST, 'Podcast'), ) title = models.CharField(max_length=200) @@ -39,3 +45,7 @@ class Tutorial(models.Model): def __str__(self): return self.title + + def is_published(self): + """Returns True if the tutorial is published""" + return self.publish diff --git a/app/static/app/js/custom.js b/app/static/app/js/custom.js index 6a751f0..62195c1 100644 --- a/app/static/app/js/custom.js +++ b/app/static/app/js/custom.js @@ -17,9 +17,7 @@ function share(title, link) { } function checkEmpty() { - var input = document.getElementById("search-bar"); - if (input.value === "" || input.value === null) { - return false; - } - return true; + var input = document.getElementById("search-bar"); + + return input.value !== "" && input.value !== null; } diff --git a/app/templates/about.html b/app/templates/about.html index 583f490..646f0b9 100644 --- a/app/templates/about.html +++ b/app/templates/about.html @@ -1,5 +1,5 @@ {% extends 'base.html'%} -{% load staticfiles %} +{% load static %} {% block content %}
diff --git a/app/templates/base.html b/app/templates/base.html index c462de0..4429267 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %} diff --git a/app/templates/contribute.html b/app/templates/contribute.html index b6b14da..c1f2987 100644 --- a/app/templates/contribute.html +++ b/app/templates/contribute.html @@ -1,5 +1,5 @@ {% extends 'base.html' %} -{% load staticfiles %} +{% load static %} {% block content %}
diff --git a/app/templates/home.html b/app/templates/home.html index 95cea7f..f9ef3e9 100644 --- a/app/templates/home.html +++ b/app/templates/home.html @@ -1,5 +1,5 @@ {% extends 'base.html' %} -{% load staticfiles %} +{% load static %} {% block content %}
diff --git a/app/templates/latest.html b/app/templates/latest.html index 41e5d00..8e72538 100644 --- a/app/templates/latest.html +++ b/app/templates/latest.html @@ -1,5 +1,5 @@ {% extends 'base.html'%} -{% load staticfiles %} +{% load static %} {% block content %} {% if tutorials %}
diff --git a/app/templates/search_results.html b/app/templates/search_results.html index 1a76b5b..57b5e22 100644 --- a/app/templates/search_results.html +++ b/app/templates/search_results.html @@ -1,5 +1,5 @@ {% extends 'home.html' %} -{% load staticfiles %} +{% load static %} {% block results %}
{% if tutorials %} diff --git a/app/templates/taglinks.html b/app/templates/taglinks.html index 52edfd2..8036eb2 100644 --- a/app/templates/taglinks.html +++ b/app/templates/taglinks.html @@ -1,6 +1,6 @@ {% extends 'base.html' %} {% block content %} -{% load staticfiles %} +{% load static %} {% if tutorials %}

Tutorials Tagged diff --git a/app/templates/tags.html b/app/templates/tags.html index 142bba2..4a13eb9 100644 --- a/app/templates/tags.html +++ b/app/templates/tags.html @@ -1,5 +1,5 @@ {% extends 'base.html' %} -{% load staticfiles %} +{% load static %} {% block content %}
diff --git a/app/templates/thankyou.html b/app/templates/thankyou.html index 63a8861..ed851b6 100644 --- a/app/templates/thankyou.html +++ b/app/templates/thankyou.html @@ -1,5 +1,5 @@ {% extends 'base.html'%} -{% load staticfiles %} +{% load static %} {% block content %}
diff --git a/app/tests/test_models.py b/app/tests/test_models.py new file mode 100644 index 0000000..a58133e --- /dev/null +++ b/app/tests/test_models.py @@ -0,0 +1,59 @@ +from django.test import TestCase +from app.models import Tag, Tutorial + +class TagModelTest(TestCase): + def test_string_representation(self): + """Valida que a representação em string da Tag é o seu próprio nome.""" + tag = Tag(name="Python") + self.assertEqual(str(tag), tag.name) + + def test_published_tutorials_count(self): + """Valida a contagem exclusiva de tutoriais publicados vinculados a uma tag (M2).""" + tag = Tag.objects.create(name="Django") + + t1 = Tutorial.objects.create( + title="Django Tutorial 1", + link="https://example.com/1", + category=Tutorial.DOCS, + publish=True + ) + t1.tags.add(tag) + + t2 = Tutorial.objects.create( + title="Django Tutorial 2", + link="https://example.com/2", + category=Tutorial.DOCS, + publish=False + ) + t2.tags.add(tag) + + self.assertEqual(tag.published_tutorials_count(), 1) + +class TutorialModelTest(TestCase): + def setUp(self): + self.tutorial = Tutorial( + title="Learn Django", + link="https://docs.djangoproject.com/", + category=Tutorial.DOCS, + publish=True + ) + self.tutorial.save() + + def test_string_representation(self): + """Valida que a representação em string do Tutorial é o seu próprio título.""" + self.assertEqual(str(self.tutorial), self.tutorial.title) + + def test_is_published(self): + """Valida que is_published retorna True para tutoriais publicados (M1).""" + self.assertTrue(self.tutorial.is_published()) + + def test_not_published(self): + """Valida que is_published retorna False para tutoriais rascunho (M1).""" + draft = Tutorial( + title="Draft", + link="http://example.com", + category=Tutorial.ARTICLE, + publish=False + ) + draft.save() + self.assertFalse(draft.is_published()) diff --git a/app/tests/test_views.py b/app/tests/test_views.py index 1419a98..9f826e6 100644 --- a/app/tests/test_views.py +++ b/app/tests/test_views.py @@ -5,30 +5,63 @@ class StaticPageTests(SimpleTestCase): def test_home_page_status_code(self): response = self.client.get('/') - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) def test_api_page_status_code(self): response = self.client.get('/api/') - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) def test_about_page_status_code(self): response = self.client.get('/about/') - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) def test_contribute_page_status_code(self): response = self.client.get('/contribute/') - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) class DynamicPageTests(TransactionTestCase): def test_latest_page_status_code(self): response = self.client.get('/latest/') - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) def test_tags_page_status_code(self): response = self.client.get('/tags/') - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) + + def test_search_query_view(self): + """Valida o funcionamento da view de busca de tutoriais com e sem filtro de categoria.""" + from app.models import Tag, Tutorial + tag = Tag.objects.create(name="django") + t = Tutorial.objects.create( + title="Django Advanced", + link="https://djangoproject.com/adv", + category=Tutorial.DOCS, + publish=True + ) + t.tags.add(tag) + + response = self.client.get('/search/?q=django') + self.assertEqual(response.status_code, 200) + + response = self.client.get('/search/?q=django&category=docs') + self.assertEqual(response.status_code, 200) + + def test_taglinks_view(self): + """Valida o funcionamento da view de listagem de tutoriais filtrados por tag.""" + from app.models import Tag, Tutorial + tag = Tag.objects.create(name="python") + t = Tutorial.objects.create( + title="Python Advanced", + link="https://python.org/adv", + category=Tutorial.DOCS, + publish=True + ) + t.tags.add(tag) + + response = self.client.get('/tags/tag=python') + self.assertEqual(response.status_code, 200) class TestTemplateNames(TransactionTestCase): diff --git a/app/views.py b/app/views.py index 297897c..85d8add 100644 --- a/app/views.py +++ b/app/views.py @@ -4,6 +4,7 @@ from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q from django.shortcuts import render +from django.views.decorators.http import require_GET from django.views.generic import TemplateView from taggie.parser import generate_tags from .models import Tag, Tutorial @@ -21,6 +22,7 @@ def get_context_data(self, **kwargs): return self.context +@require_GET def search_query(request): """view for the search results""" query = request.GET.get('q').lower() @@ -64,6 +66,7 @@ def search_query(request): return render(request, 'search_results.html', context) +@require_GET def latest(request): """view for the latest tutorial entries""" tutorials = Tutorial.objects.all().filter(publish=True).order_by('-id')[:10] @@ -74,6 +77,7 @@ def latest(request): return render(request, 'latest.html', context) +@require_GET def tags(request): """view for the tags""" tags = cache.get_or_set(cache_constants.ALL_TAGS, Tag.objects.all(), None) @@ -84,6 +88,7 @@ def tags(request): return render(request, 'tags.html', context) +@require_GET def taglinks(request, tagname): """view for the tutorials with the {tagname}""" taglist = [] @@ -97,6 +102,7 @@ def taglinks(request, tagname): return render(request, 'taglinks.html', context) +@require_GET def about(request): """about view""" return render(request, 'about.html', {'title': 'About'}) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c0c502f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = tutorialdb.settings +python_files = tests.py test_*.py *_tests.py diff --git a/requirements.txt b/requirements.txt index 8534398..6cdea03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,10 @@ lxml>=4.4.0 djangorestframework>=3.10.0 python-dotenv>=0.10.0 django-import-export>=1.0.0 -whitenoise==3.3.1 +whitenoise>=6.0.0 dj-database-url==0.4.2 gunicorn==19.7.1 -psycopg2==2.7.3.2 \ No newline at end of file +psycopg2-binary>=2.7.3.2 +pytest>=7.0.0 +pytest-django>=4.5.2 +pytest-cov>=4.0.0 \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..3b2600d --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,6 @@ +sonar.projectKey=PedroVFSantos_tutorialdb_ES2 +sonar.organization=pedrovfsantos +sonar.python.coverage.reportPaths=coverage.xml +sonar.sources=app +sonar.tests=app/tests +sonar.language=py diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/taggie/parser.py b/taggie/parser.py index 05677ea..6cdd3f4 100644 --- a/taggie/parser.py +++ b/taggie/parser.py @@ -81,7 +81,7 @@ def get_tutorial(link): """get request to the tutorial link""" res = None try: - res = requests.get(link, headers={ + res = requests.get(link, headers={ # NOSONAR 'User-Agent': 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36' + '(KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36' diff --git a/tutorialdb/settings.py b/tutorialdb/settings.py index 5cf602a..a6ad7a3 100644 --- a/tutorialdb/settings.py +++ b/tutorialdb/settings.py @@ -11,13 +11,14 @@ try: LOCAL_HOST = os.environ['LOCAL_HOST'] # your local IP to test the site on your network -except: +except KeyError: LOCAL_HOST = None -DEBUG = True +DEBUG = False ALLOWED_HOSTS = [ '127.0.0.1', + 'localhost', 'tutorialdb.pythonanywhere.com', 'tutorialdb-app.herokuapp.com', ] @@ -126,7 +127,7 @@ USE_TZ = True -PROJECT_ROOT = os.path.join(os.path.abspath(__file__)) +PROJECT_ROOT = BASE_DIR # Location of all static files STATIC_URL = '/static/' @@ -137,7 +138,7 @@ os.path.join(PROJECT_ROOT, 'static'), ) -STATICFILES_STORAGE = 'whitenoise.django.GzipManifestStaticFilesStorage' +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' prod_db = dj_database_url.config(conn_max_age=500) DATABASES['default'].update(prod_db) \ No newline at end of file