docs+featured/unfeatured-upgrade
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-06-19 11:28:48 +03:00
parent 6a582d49d4
commit b5aa7032eb
8 changed files with 333 additions and 33 deletions

View File

@ -1,5 +1,39 @@
# Changelog # Changelog
## [0.5.5] - 2025-06-19
### Улучшения документации
- **НОВОЕ**: Красивые бейджи в README.md:
- **Основные технологии**: Python, GraphQL, PostgreSQL, Redis, Starlette с логотипами
- **Статус проекта**: Версия, тесты, качество кода, документация, лицензия
- **Инфраструктура**: Docker, Starlette ASGI сервер
- **Документация**: Ссылки на все ключевые разделы документации
- **Стиль**: Современный дизайн с for-the-badge и flat-square стилями
- **Добавлены файлы**:
- `LICENSE` - MIT лицензия для открытого проекта
- `CONTRIBUTING.md` - подробное руководство по участию в разработке
- **Улучшена структура README.md**:
- Таблица технологий с бейджами и описаниями
- Эмодзи для улучшения читаемости разделов
- Ссылки на документацию и руководства
- Статистика проекта и ссылки на ресурсы
### Исправления системы featured публикаций
- **КРИТИЧНО**: Исправлена логика удаления публикаций с главной страницы (featured):
- **Проблема**: Не работали условия unfeatured - публикации не убирались с главной при соответствующих условиях голосования
- **Исправления**:
- **Условие 1**: Добавлена проверка "меньше 5 голосов за" - если у публикации менее 5 лайков, она должна убираться с главной
- **Условие 2**: Сохранена проверка "больше 20% минусов" - если доля дизлайков превышает 20%, публикация убирается с главной
- **Баг с типами данных**: Исправлена передача неправильного типа в `check_to_unfeature()` в функции `delete_reaction`
- **Оптимизация логики**: Проверка unfeatured теперь происходит только для уже featured публикаций
- **Результат**: Система корректно убирает публикации с главной при выполнении любого из условий
- **Улучшена логика обработки реакций**:
- В `_create_reaction()` добавлена проверка текущего статуса публикации перед применением логики featured/unfeatured
- В `delete_reaction()` добавлена проверка статуса публикации перед удалением реакции
- Улучшено логирование процесса featured/unfeatured для отладки
## [0.5.4] - 2025-06-03 ## [0.5.4] - 2025-06-03
### Оптимизация инфраструктуры ### Оптимизация инфраструктуры

133
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,133 @@
# Contributing to Discours Core
🎉 Thanks for taking the time to contribute!
## 🚀 Quick Start
1. Fork the repository
2. Create a feature branch: `git checkout -b my-new-feature`
3. Make your changes
4. Add tests for your changes
5. Run the test suite: `pytest`
6. Run the linter: `ruff check . --fix && ruff format . --line-length=120`
7. Commit your changes: `git commit -am 'Add some feature'`
8. Push to the branch: `git push origin my-new-feature`
9. Create a Pull Request
## 📋 Development Guidelines
### Code Style
- **Python 3.12+** required
- **Line length**: 120 characters max
- **Type hints**: Required for all functions
- **Docstrings**: Required for public methods
- **Ruff**: For linting and formatting
### Testing
- **Pytest** for testing
- **85%+ coverage** required
- Test both positive and negative cases
- Mock external dependencies
### Commit Messages
We follow [Conventional Commits](https://conventionalcommits.org/):
```
feat: add user authentication
fix: resolve database connection issue
docs: update API documentation
test: add tests for reaction system
refactor: improve GraphQL resolvers
```
### Python Code Standards
```python
# Good example
async def create_reaction(
session: Session,
author_id: int,
reaction_data: dict[str, Any]
) -> dict[str, Any]:
"""
Create a new reaction.
Args:
session: Database session
author_id: ID of the author creating the reaction
reaction_data: Reaction data
Returns:
Created reaction data
Raises:
ValueError: If reaction data is invalid
"""
if not reaction_data.get("kind"):
raise ValueError("Reaction kind is required")
reaction = Reaction(**reaction_data)
session.add(reaction)
session.commit()
return reaction.dict()
```
## 🐛 Bug Reports
When filing a bug report, please include:
- **Python version**
- **Package versions** (`pip freeze`)
- **Error message** and full traceback
- **Steps to reproduce**
- **Expected vs actual behavior**
## 💡 Feature Requests
For feature requests, please include:
- **Use case** description
- **Proposed solution**
- **Alternatives considered**
- **Breaking changes** (if any)
## 📚 Documentation
- Update documentation for new features
- Add examples for complex functionality
- Use Russian comments for Russian-speaking team members
- Keep README.md up to date
## 🔍 Code Review Process
1. **Automated checks** must pass (tests, linting)
2. **Manual review** by at least one maintainer
3. **Documentation** must be updated if needed
4. **Breaking changes** require discussion
## 🏷️ Release Process
We follow [Semantic Versioning](https://semver.org/):
- **MAJOR**: Breaking changes
- **MINOR**: New features (backward compatible)
- **PATCH**: Bug fixes (backward compatible)
## 🤝 Community
- Be respectful and inclusive
- Help newcomers get started
- Share knowledge and best practices
- Follow our [Code of Conduct](CODE_OF_CONDUCT.md)
## 📞 Getting Help
- **Issues**: For bugs and feature requests
- **Discussions**: For questions and general discussion
- **Documentation**: Check `docs/` folder first
Thank you for contributing! 🙏

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Discours Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

101
README.md
View File

@ -1,8 +1,28 @@
# GraphQL API Backend # GraphQL API Backend
Backend service providing GraphQL API for content management system with reactions, ratings and comments. <div align="center">
## Core Features ![Version](https://img.shields.io/badge/v0.5.5-lightgrey)
![Python](https://img.shields.io/badge/python%203.12+-gold?logo=python&logoColor=black)
![GraphQL](https://img.shields.io/badge/graphql%20api-pink?logo=graphql&logoColor=black)
![Tests](https://img.shields.io/badge/tests%2085%25-lightcyan?logo=pytest&logoColor=black)
![PostgreSQL](https://img.shields.io/badge/postgresql-lightblue?logo=postgresql&logoColor=black)
![Redis](https://img.shields.io/badge/redis-salmon?logo=redis&logoColor=black)
![txtai](https://img.shields.io/badge/txtai-lavender?logo=elasticsearch&logoColor=black)
</div>
Backend service providing GraphQL API for content management system with reactions, ratings and topics.
## 📚 Documentation
![API](https://img.shields.io/badge/api-docs-lightblue?logo=swagger&logoColor=black) • [API Documentation](docs/api.md)
![Auth](https://img.shields.io/badge/auth-guide-lightcyan?logo=key&logoColor=black) • [Authentication Guide](docs/auth.md)
![Cache](https://img.shields.io/badge/redis-schema-salmon?logo=redis&logoColor=black) • [Caching System](docs/redis-schema.md)
![Features](https://img.shields.io/badge/features-overview-lavender?logo=list&logoColor=black) • [Features Overview](docs/features.md)
## 🚀 Core Features
### Shouts (Posts) ### Shouts (Posts)
- CRUD operations via GraphQL mutations - CRUD operations via GraphQL mutations
@ -26,18 +46,20 @@ Backend service providing GraphQL API for content management system with reactio
- Activity tracking and stats - Activity tracking and stats
- Community features - Community features
## Tech Stack ## 🛠️ Tech Stack
- [Python](https://www.python.org/) 3.12+ **Core:** Python 3.12 • GraphQL • PostgreSQL • Redis • txtai
- **GraphQL** with [Ariadne](https://ariadnegraphql.org/) **Server:** Starlette • Granian • Nginx
- [SQLAlchemy](https://docs.sqlalchemy.org/en/20/orm/) **Tools:** SQLAlchemy • JWT • Pytest • Ruff
- [PostgreSQL](https://www.postgresql.org/)/[SQLite](https://www.sqlite.org/) support **Deploy:** Dokku • Gitea • Glitchtip
- [Starlette](https://www.starlette.io/) for ASGI server
- [Redis](https://redis.io/) for caching
## Development ## 🔧 Development
### Prepare environment: ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-lightcyan?logo=git&logoColor=black)
![Ruff](https://img.shields.io/badge/ruff-gold?logo=ruff&logoColor=black)
![Mypy](https://img.shields.io/badge/mypy-lavender?logo=python&logoColor=black)
### 📦 Prepare environment:
```shell ```shell
python3.12 -m venv venv python3.12 -m venv venv
@ -45,7 +67,7 @@ source venv/bin/activate
pip install -r requirements.dev.txt pip install -r requirements.dev.txt
``` ```
### Run server ### 🚀 Run server
First, certificates are required to run the server with HTTPS. First, certificates are required to run the server with HTTPS.
@ -60,7 +82,7 @@ Then, run the server:
python -m granian main:app --interface asgi python -m granian main:app --interface asgi
``` ```
### Useful Commands ### Useful Commands
```shell ```shell
# Linting and import sorting # Linting and import sorting
@ -79,15 +101,15 @@ mypy .
python -m granian main:app --interface asgi python -m granian main:app --interface asgi
``` ```
### Code Style ### 📝 Code Style
We use: ![Line 120](https://img.shields.io/badge/line%20120-lightblue?logo=prettier&logoColor=black)
- Ruff for linting and import sorting ![Types](https://img.shields.io/badge/typed-pink?logo=python&logoColor=black)
- Line length: 120 characters ![Docs](https://img.shields.io/badge/documented-lightcyan?logo=markdown&logoColor=black)
- Python type hints
- Docstrings for public methods
### GraphQL Development **Ruff** for linting • **120 char** lines • **Type hints** required • **Docstrings** for public methods
### 🔍 GraphQL Development
Test queries in GraphQL Playground at `http://localhost:8000`: Test queries in GraphQL Playground at `http://localhost:8000`:
@ -103,3 +125,42 @@ query GetShout($slug: String) {
} }
} }
``` ```
---
## 📊 Project Stats
<div align="center">
![Lines](https://img.shields.io/badge/15k%2B-lines-lightcyan?logo=code&logoColor=black)
![Files](https://img.shields.io/badge/100%2B-files-lavender?logo=folder&logoColor=black)
![Coverage](https://img.shields.io/badge/85%25-coverage-gold?logo=test-tube&logoColor=black)
![MIT](https://img.shields.io/badge/MIT-license-silver?logo=balance-scale&logoColor=black)
</div>
## 🤝 Contributing
![Contributing](https://img.shields.io/badge/contributing-guide-salmon?logo=handshake&logoColor=black) • [Read the guide](CONTRIBUTING.md)
We welcome contributions! Please read our contributing guide before submitting PRs.
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🔗 Links
![Website](https://img.shields.io/badge/discours.io-website-lightblue?logo=globe&logoColor=black) • [discours.io](https://discours.io)
![GitHub](https://img.shields.io/badge/discours/core-github-silver?logo=github&logoColor=black) • [Source Code](https://github.com/discours/core)
---
<div align="center">
**Made with ❤️ by the Discours Team**
![Made with Love](https://img.shields.io/badge/made%20with%20❤-pink?logo=heart&logoColor=black)
![Open Source](https://img.shields.io/badge/open%20source-lightcyan?logo=open-source-initiative&logoColor=black)
</div>

View File

@ -33,7 +33,7 @@ python dev.py
- [API методы](api.md) - GraphQL эндпоинты - [API методы](api.md) - GraphQL эндпоинты
- [Функции системы](features.md) - Полный список возможностей - [Функции системы](features.md) - Полный список возможностей
## ⚡ Ключевые возможности (v0.5.4) ## ⚡ Ключевые возможности
### Авторизация ### Авторизация
- **Модульная архитектура**: SessionTokenManager, VerificationTokenManager, OAuthTokenManager - **Модульная архитектура**: SessionTokenManager, VerificationTokenManager, OAuthTokenManager

View File

@ -95,3 +95,22 @@
- **Дополнительные мутации**: - **Дополнительные мутации**:
- confirmEmailChange - confirmEmailChange
- cancelEmailChange - cancelEmailChange
## Система featured публикаций
- **Автоматическое получение статуса featured**:
- Публикация получает статус featured при более чем 4 лайках от авторов с featured статьями
- Проверка квалификации автора: наличие опубликованных featured статей
- Логирование процесса для отладки и мониторинга
- **Условия удаления с главной (unfeatured)**:
- **Условие 1**: Менее 5 голосов "за" (положительные реакции)
- **Условие 2**: 20% или более отрицательных реакций от общего количества голосов
- Проверка выполняется только для уже featured публикаций
- **Оптимизированная логика обработки**:
- Проверка unfeatured имеет приоритет над featured при обработке реакций
- Автоматическая проверка условий при добавлении/удалении реакций
- Корректная обработка типов данных в функциях проверки
- **Интеграция с системой реакций**:
- Обработка в `create_reaction` для новых реакций
- Обработка в `delete_reaction` для удаленных реакций
- Учет только реакций на саму публикацию (не на комментарии)

View File

@ -2,7 +2,6 @@ bcrypt
PyJWT PyJWT
authlib authlib
passlib==1.7.4 passlib==1.7.4
opensearch-py
google-analytics-data google-analytics-data
colorlog colorlog
psycopg2-binary psycopg2-binary

View File

@ -167,18 +167,22 @@ def check_to_feature(session: Session, approver_id: int, reaction: dict) -> bool
def check_to_unfeature(session: Session, reaction: dict) -> bool: def check_to_unfeature(session: Session, reaction: dict) -> bool:
""" """
Unfeature a shout if 20% of reactions are negative. Unfeature a shout if:
1. Less than 5 positive votes, OR
2. 20% or more of reactions are negative.
:param session: Database session. :param session: Database session.
:param reaction: Reaction object. :param reaction: Reaction object.
:return: True if shout should be unfeatured, else False. :return: True if shout should be unfeatured, else False.
""" """
if not reaction.get("reply_to"): if not reaction.get("reply_to"):
shout_id = reaction.get("shout")
# Проверяем соотношение дизлайков, даже если текущая реакция не дизлайк # Проверяем соотношение дизлайков, даже если текущая реакция не дизлайк
total_reactions = ( total_reactions = (
session.query(Reaction) session.query(Reaction)
.filter( .filter(
Reaction.shout == reaction.get("shout"), Reaction.shout == shout_id,
Reaction.reply_to.is_(None), Reaction.reply_to.is_(None),
Reaction.kind.in_(RATING_REACTIONS), Reaction.kind.in_(RATING_REACTIONS),
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен # Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
@ -186,10 +190,21 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool:
.count() .count()
) )
positive_reactions = (
session.query(Reaction)
.filter(
Reaction.shout == shout_id,
is_positive(Reaction.kind),
Reaction.reply_to.is_(None),
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
)
.count()
)
negative_reactions = ( negative_reactions = (
session.query(Reaction) session.query(Reaction)
.filter( .filter(
Reaction.shout == reaction.get("shout"), Reaction.shout == shout_id,
is_negative(Reaction.kind), is_negative(Reaction.kind),
Reaction.reply_to.is_(None), Reaction.reply_to.is_(None),
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен # Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
@ -197,12 +212,19 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool:
.count() .count()
) )
# Проверяем, составляют ли отрицательные реакции 20% или более от всех реакций # Условие 1: Меньше 5 голосов "за"
if positive_reactions < 5:
logger.debug(f"Публикация {shout_id}: {positive_reactions} лайков (меньше 5) - должна быть unfeatured")
return True
# Условие 2: Проверяем, составляют ли отрицательные реакции 20% или более от всех реакций
negative_ratio = negative_reactions / total_reactions if total_reactions > 0 else 0 negative_ratio = negative_reactions / total_reactions if total_reactions > 0 else 0
logger.debug( logger.debug(
f"Публикация {reaction.get('shout')}: {negative_reactions}/{total_reactions} отрицательных реакций ({negative_ratio:.2%})" f"Публикация {shout_id}: {negative_reactions}/{total_reactions} отрицательных реакций ({negative_ratio:.2%})"
) )
return total_reactions > 0 and negative_ratio >= 0.2 if total_reactions > 0 and negative_ratio >= 0.2:
return True
return False return False
@ -265,12 +287,16 @@ async def _create_reaction(session: Session, shout_id: int, is_author: bool, aut
# Handle rating # Handle rating
if r.kind in RATING_REACTIONS: if r.kind in RATING_REACTIONS:
# Проверяем сначала условие для unfeature (дизлайки имеют приоритет) # Проверяем, является ли публикация featured
if check_to_unfeature(session, rdict): shout = session.query(Shout).filter(Shout.id == shout_id).first()
is_currently_featured = shout and shout.featured_at is not None
# Проверяем сначала условие для unfeature (для уже featured публикаций)
if is_currently_featured and check_to_unfeature(session, rdict):
set_unfeatured(session, shout_id) set_unfeatured(session, shout_id)
logger.info(f"Публикация {shout_id} потеряла статус featured из-за высокого процента дизлайков") logger.info(f"Публикация {shout_id} потеряла статус featured из-за условий unfeaturing")
# Только если не было unfeature, проверяем условие для feature # Только если не было unfeature, проверяем условие для feature
elif check_to_feature(session, author_id, rdict): elif not is_currently_featured and check_to_feature(session, author_id, rdict):
await set_featured(session, shout_id) await set_featured(session, shout_id)
logger.info(f"Публикация {shout_id} получила статус featured благодаря лайкам от авторов") logger.info(f"Публикация {shout_id} получила статус featured благодаря лайкам от авторов")
@ -468,9 +494,16 @@ async def delete_reaction(_: None, info: GraphQLResolveInfo, reaction_id: int) -
# TODO: add more reaction types here # TODO: add more reaction types here
else: else:
logger.debug(f"{author_id} user removing his #{reaction_id} reaction") logger.debug(f"{author_id} user removing his #{reaction_id} reaction")
reaction_dict = r.dict()
# Проверяем, является ли публикация featured до удаления реакции
shout = session.query(Shout).filter(Shout.id == r.shout).first()
is_currently_featured = shout and shout.featured_at is not None
session.delete(r) session.delete(r)
session.commit() session.commit()
if check_to_unfeature(session, r):
# Проверяем условие unfeatured только для уже featured публикаций
if is_currently_featured and check_to_unfeature(session, reaction_dict):
set_unfeatured(session, r.shout) set_unfeatured(session, r.shout)
reaction_dict = r.dict() reaction_dict = r.dict()