Skip to content

Commit c7691ab

Browse files
committed
Merge branch 'miscs'
2 parents 105c058 + f1bccde commit c7691ab

17 files changed

+337
-221
lines changed

.pre-commit-config.yaml

+6-6
Original file line numberDiff line numberDiff line change
@@ -51,28 +51,28 @@ repos:
5151
- --allow-past-years
5252

5353
- repo: https://github.com/adamchainz/django-upgrade
54-
rev: "1.22.1"
54+
rev: "1.23.1"
5555
hooks:
5656
- id: django-upgrade
5757
args: ["--target-version", "5.0"]
5858

5959
- repo: https://github.com/asottile/pyupgrade
60-
rev: v3.19.0
60+
rev: v3.19.1
6161
hooks:
6262
- id: pyupgrade
6363
args: [--py312-plus]
6464

6565
- repo: https://github.com/astral-sh/ruff-pre-commit
6666
# Ruff version.
67-
rev: v0.9.2
67+
rev: v0.9.10
6868
hooks:
6969
- id: ruff
7070
args: [--fix, --preview]
7171
- id: ruff-format
7272
args: [--preview]
7373

7474
- repo: https://github.com/seddonym/import-linter
75-
rev: v2.1
75+
rev: v2.2
7676
hooks:
7777
- id: import-linter
7878

@@ -91,7 +91,7 @@ repos:
9191
args: [-e, SC1091]
9292

9393
- repo: https://github.com/thibaudcolas/pre-commit-stylelint
94-
rev: v16.10.0
94+
rev: v16.15.0
9595
hooks:
9696
- id: stylelint
9797
args: [--fix]
@@ -100,7 +100,7 @@ repos:
100100
101101

102102
- repo: https://github.com/pre-commit/mirrors-eslint
103-
rev: "v9.15.0"
103+
rev: "v9.22.0"
104104
hooks:
105105
- id: eslint
106106
args: [--fix]

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
## 25.03.1
6+
7+
- Can filter tags in tags admin.
8+
- Enable tags hierarchy when updating an article on the details page.
9+
510
## 25.01.1
611

712
- Correct footer background on dark mode.

legadilo/constants.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Legadilo
2+
# Copyright (C) 2023-2025 by Legadilo contributors.
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU Affero General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Affero General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
SEARCHED_TEXT_MIN_LENGTH = 3

legadilo/feeds/models/feed.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from legadilo.reading.models.tag import Tag
3333
from legadilo.users.models import User
3434

35+
from ...constants import SEARCHED_TEXT_MIN_LENGTH
3536
from ...users.models import Notification
3637
from ...utils.time_utils import utcnow
3738
from .. import constants as feeds_constants
@@ -216,7 +217,7 @@ def get_by_categories(
216217
.order_by("category__title", "id")
217218
)
218219

219-
if len(searched_text) > 3: # noqa: PLR2004 Magic value used in comparison
220+
if len(searched_text) > SEARCHED_TEXT_MIN_LENGTH:
220221
qs = qs.filter(title__icontains=searched_text)
221222

222223
for feed in qs:

legadilo/reading/models/tag.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from django.db.models.functions import Coalesce
2626
from slugify import slugify
2727

28+
from legadilo.constants import SEARCHED_TEXT_MIN_LENGTH
2829
from legadilo.core import constants as core_constants
2930
from legadilo.core.forms import FormChoices
3031
from legadilo.reading import constants
@@ -169,14 +170,19 @@ def get_or_create_from_list(self, user: User, titles_or_slugs: Iterable[str]) ->
169170

170171
return [*existing_tags, *tags_to_create]
171172

172-
def list_for_admin(self, user: User) -> list[Tag]:
173-
return list(
173+
def list_for_admin(self, user: User, searched_text: str = "") -> list[Tag]:
174+
qs = (
174175
self.get_queryset()
175176
.for_user(user)
176177
.annotate(annot_articles_count=models.Count("articles"))
177178
.order_by("title")
178179
)
179180

181+
if len(searched_text) > SEARCHED_TEXT_MIN_LENGTH:
182+
qs = qs.filter(title__icontains=searched_text)
183+
184+
return list(qs)
185+
180186

181187
class Tag(models.Model):
182188
title = models.CharField(max_length=50)

legadilo/reading/services/article_fetching.py

+17-17
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,8 @@ async def _get_page_content(url: str) -> tuple[str, BeautifulSoup, str | None]:
189189
)
190190
if (
191191
(http_equiv_refresh := soup.find("meta", attrs={"http-equiv": "refresh"}))
192-
and (http_equiv_refresh_value := http_equiv_refresh.get("content"))
193-
and (http_equiv_refresh_url := _parse_http_equiv_refresh(http_equiv_refresh_value))
192+
and (http_equiv_refresh_value := http_equiv_refresh.get("content")) # type: ignore[union-attr]
193+
and (http_equiv_refresh_url := _parse_http_equiv_refresh(http_equiv_refresh_value)) # type: ignore[arg-type]
194194
):
195195
url = http_equiv_refresh_url
196196
continue
@@ -228,7 +228,7 @@ def _build_article_data_from_soup(
228228
external_article_id="",
229229
source_title=_get_site_title(fetched_url, soup),
230230
title=forced_title or _get_title(soup),
231-
summary=_get_summary(soup, content),
231+
summary=_get_summary(soup),
232232
content=content,
233233
authors=tuple(_get_authors(soup)),
234234
contributors=(),
@@ -242,7 +242,7 @@ def _build_article_data_from_soup(
242242
)
243243

244244

245-
def _get_title(soup: BeautifulSoup) -> str:
245+
def _get_title(soup) -> str:
246246
title = ""
247247
if (og_title := soup.find("meta", attrs={"property": "og:title"})) and og_title.get("content"):
248248
title = og_title.get("content")
@@ -262,7 +262,7 @@ def _get_title(soup: BeautifulSoup) -> str:
262262
return title
263263

264264

265-
def _get_site_title(fetched_url: str, soup: BeautifulSoup) -> str:
265+
def _get_site_title(fetched_url: str, soup) -> str:
266266
site_title = urlparse(fetched_url).netloc
267267
if (og_site_name := soup.find("meta", attrs={"property": "og:site_name"})) and og_site_name.get(
268268
"content"
@@ -274,7 +274,7 @@ def _get_site_title(fetched_url: str, soup: BeautifulSoup) -> str:
274274
return site_title
275275

276276

277-
def _get_summary(soup: BeautifulSoup, content: str) -> str:
277+
def _get_summary(soup) -> str:
278278
summary = ""
279279
if (
280280
og_description := soup.find("meta", attrs={"property": "og:description"})
@@ -302,7 +302,7 @@ def _get_fallback_summary_from_content(content: str) -> str:
302302
)
303303

304304

305-
def _get_content(soup: BeautifulSoup) -> str:
305+
def _get_content(soup) -> str:
306306
articles = soup.find_all("article")
307307
article_content = None
308308
if len(articles) > 1:
@@ -323,7 +323,7 @@ def _get_content(soup: BeautifulSoup) -> str:
323323
return str(article_content)
324324

325325

326-
def _parse_multiple_articles(soup: BeautifulSoup):
326+
def _parse_multiple_articles(soup):
327327
for article in soup.find_all(["article", "section"]):
328328
attrs = set()
329329
if article_id := article.get("id"):
@@ -350,20 +350,20 @@ def _parse_multiple_articles(soup: BeautifulSoup):
350350
return soup.find("article")
351351

352352

353-
def _extract_tag_from_content(soup: BeautifulSoup, tag_name: str):
353+
def _extract_tag_from_content(soup, tag_name: str):
354354
for tag in soup.find_all(tag_name):
355355
tag.extract()
356356

357357

358-
def _get_authors(soup: BeautifulSoup) -> list[str]:
358+
def _get_authors(soup) -> list[str]:
359359
authors = []
360360
if (meta_author := soup.find("meta", {"name": "author"})) and meta_author.get("content"):
361361
authors = [meta_author.get("content")]
362362

363363
return authors
364364

365365

366-
def _get_tags(soup: BeautifulSoup) -> list[str]:
366+
def _get_tags(soup) -> list[str]:
367367
tags = set()
368368

369369
if article_tags := soup.find_all("meta", attrs={"property": "article:tag"}):
@@ -388,7 +388,7 @@ def parse_tags_list(tags_str: str) -> set[str]:
388388
return parsed_tags
389389

390390

391-
def _get_link(fetched_url: str, soup: BeautifulSoup) -> str:
391+
def _get_link(fetched_url: str, soup) -> str:
392392
link = fetched_url
393393
if (canonical_link := soup.find("link", {"rel": "canonical"})) and canonical_link.get("href"):
394394
link = canonical_link.get("href")
@@ -399,7 +399,7 @@ def _get_link(fetched_url: str, soup: BeautifulSoup) -> str:
399399
return fetched_url
400400

401401

402-
def _get_preview_picture_url(fetched_url, soup: BeautifulSoup) -> str:
402+
def _get_preview_picture_url(fetched_url, soup) -> str:
403403
preview_picture_url = ""
404404

405405
if (og_image := soup.find("meta", attrs={"property": "og:image"})) and (
@@ -432,7 +432,7 @@ def _get_preview_picture_url(fetched_url, soup: BeautifulSoup) -> str:
432432
return preview_picture_url
433433

434434

435-
def _get_published_at(soup: BeautifulSoup) -> datetime | None:
435+
def _get_published_at(soup) -> datetime | None:
436436
published_at = None
437437
if (
438438
article_published_time := soup.find("meta", attrs={"property": "article:published_time"})
@@ -442,7 +442,7 @@ def _get_published_at(soup: BeautifulSoup) -> datetime | None:
442442
return published_at
443443

444444

445-
def _get_updated_at(soup: BeautifulSoup) -> datetime | None:
445+
def _get_updated_at(soup) -> datetime | None:
446446
updated_at = None
447447
if (
448448
article_modified_time := soup.find("meta", attrs={"property": "article:modified_time"})
@@ -452,11 +452,11 @@ def _get_updated_at(soup: BeautifulSoup) -> datetime | None:
452452
return updated_at
453453

454454

455-
def _get_lang(soup: BeautifulSoup, content_language: str | None) -> str:
455+
def _get_lang(soup, content_language: str | None) -> str:
456456
if not soup.find("html") or (language := soup.find("html").get("lang")) is None:
457457
language = content_language
458458

459-
return language
459+
return str(language or "")
460460

461461

462462
def _build_table_of_content(content: str) -> tuple[str, list[TableOfContentTopItem]]:

legadilo/reading/tests/test_models/test_tag.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def test_associate_tag_with_sub_tags_clear_existing(self, user, django_assert_nu
6666
class TestTagManager:
6767
@pytest.fixture(autouse=True)
6868
def _setup_data(self, user):
69-
self.tag1 = TagFactory(user=user)
69+
self.tag1 = TagFactory(user=user, title="Super tag for search")
7070
self.tag2 = TagFactory(user=user)
7171
self.existing_tag_with_spaces = TagFactory(
7272
user=user, title="Existing tag with spaces", slug="existing-tag-with-spaces"
@@ -159,6 +159,13 @@ def test_list_for_admin(self, user):
159159
assert all_tags[1].annot_articles_count == 1 # type: ignore[attr-defined]
160160
assert all_tags[2].annot_articles_count == 0 # type: ignore[attr-defined]
161161

162+
def test_list_for_admin_with_search(self, user):
163+
all_tags = Tag.objects.list_for_admin(user, searched_text="seArcH")
164+
165+
assert all_tags == [
166+
self.tag1,
167+
]
168+
162169

163170
@pytest.mark.django_db
164171
class TestArticleTagQuerySet:

legadilo/reading/views/article_details_views.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def article_details_view(
7171
slug=article_slug,
7272
user=request.user,
7373
)
74-
tag_choices = Tag.objects.get_all_choices(request.user)
74+
tag_choices, hierarchy = Tag.objects.get_all_choices_with_hierarchy(request.user)
7575
if request.method == "POST":
7676
status, edit_article_form, article = _handle_update(request, article, tag_choices)
7777
else:
@@ -97,6 +97,7 @@ def article_details_view(
9797
"edit_article_form": edit_article_form,
9898
"comment_article_form": CommentArticleForm(),
9999
"from_url": get_from_url_for_article_details(request, request.GET),
100+
"tags_hierarchy": hierarchy,
100101
},
101102
status=status,
102103
)

legadilo/reading/views/search_views.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from legadilo.users.user_types import AuthenticatedHttpRequest
3838
from legadilo.utils.security import full_sanitize
3939

40+
from ...constants import SEARCHED_TEXT_MIN_LENGTH
4041
from ...users.models import User
4142
from ...utils.collections_utils import alist, aset
4243
from ...utils.validators import is_url_valid
@@ -45,7 +46,7 @@
4546

4647
class SearchForm(forms.Form):
4748
# Main fields.
48-
q = forms.CharField(required=True, min_length=4, label=_("Search query"))
49+
q = forms.CharField(required=True, min_length=SEARCHED_TEXT_MIN_LENGTH, label=_("Search query"))
4950
search_type = forms.ChoiceField(
5051
required=False,
5152
choices=constants.ArticleSearchType.choices,
@@ -125,7 +126,8 @@ def clean_q(self):
125126
q = full_sanitize(q)
126127
if len(q) < self.fields["q"].min_length: # type: ignore[attr-defined]
127128
raise ValidationError(
128-
"You must at least enter 3 characters", code="q-too-short-after-cleaning"
129+
f"You must at least enter {SEARCHED_TEXT_MIN_LENGTH} characters",
130+
code="q-too-short-after-cleaning",
129131
)
130132

131133
return q

legadilo/reading/views/tags_admin_views.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,21 @@
3232
from legadilo.reading.models.tag import SubTagMapping
3333
from legadilo.users.models import User
3434
from legadilo.users.user_types import AuthenticatedHttpRequest
35+
from legadilo.utils.security import full_sanitize
3536

3637

3738
@require_GET
3839
@login_required
3940
def tags_admin_view(request: AuthenticatedHttpRequest) -> TemplateResponse:
41+
searched_text = full_sanitize(request.GET.get("q", ""))
42+
4043
return TemplateResponse(
41-
request, "reading/tags_admin.html", {"tags": Tag.objects.list_for_admin(request.user)}
44+
request,
45+
"reading/tags_admin.html",
46+
{
47+
"tags": Tag.objects.list_for_admin(request.user, searched_text=searched_text),
48+
"searched_text": searched_text,
49+
},
4250
)
4351

4452

legadilo/static/css/grid.css

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.grid {
22
display: grid;
3-
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
3+
grid-template-columns: repeat(auto-fit, 24%);
44
grid-gap: 1rem;
55
grid-auto-rows: 1fr;
66
}

legadilo/templates/feeds/feeds_admin.html

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ <h1>{% translate "Feeds admin" %}</h1>
2727
value="{{ searched_text }}"
2828
aria-label="Search for feeds"
2929
aria-describedby="feeds-search-glass" />
30+
<button class="btn btn-outline-secondary" type="submit">{% translate "Search" %}</button>
3031
</div>
3132
</form>
3233
<div id="feeds-by-categories-accordion" class="accordion mt-3">

legadilo/templates/reading/article_details.html

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<script src="{% static 'js/tag_edition.js' %}"
1010
nonce="{{request.csp_nonce}}"
1111
type="module"></script>
12+
{{ tags_hierarchy|json_script:"tags-hierarchy" }}
1213
{% endblock page_js %}
1314
{% block page_css %}
1415
<link rel="stylesheet"

0 commit comments

Comments
 (0)