diff --git a/CHANGELOG.md b/CHANGELOG.md index f0eaa111..cb58b47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # 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 ### Оптимизация инфраструктуры diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..6a9db8a5 --- /dev/null +++ b/CONTRIBUTING.md @@ -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! 🙏 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d2071f96 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 0d17826e..a61b1f46 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,28 @@ # GraphQL API Backend -Backend service providing GraphQL API for content management system with reactions, ratings and comments. +
-## 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) + +
+ +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) - CRUD operations via GraphQL mutations @@ -26,18 +46,20 @@ Backend service providing GraphQL API for content management system with reactio - Activity tracking and stats - Community features -## Tech Stack +## 🛠️ Tech Stack -- [Python](https://www.python.org/) 3.12+ -- **GraphQL** with [Ariadne](https://ariadnegraphql.org/) -- [SQLAlchemy](https://docs.sqlalchemy.org/en/20/orm/) -- [PostgreSQL](https://www.postgresql.org/)/[SQLite](https://www.sqlite.org/) support -- [Starlette](https://www.starlette.io/) for ASGI server -- [Redis](https://redis.io/) for caching +**Core:** Python 3.12 • GraphQL • PostgreSQL • Redis • txtai +**Server:** Starlette • Granian • Nginx +**Tools:** SQLAlchemy • JWT • Pytest • Ruff +**Deploy:** Dokku • Gitea • Glitchtip -## 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 python3.12 -m venv venv @@ -45,7 +67,7 @@ source venv/bin/activate pip install -r requirements.dev.txt ``` -### Run server +### 🚀 Run server 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 ``` -### Useful Commands +### ⚡ Useful Commands ```shell # Linting and import sorting @@ -79,15 +101,15 @@ mypy . python -m granian main:app --interface asgi ``` -### Code Style +### 📝 Code Style -We use: -- Ruff for linting and import sorting -- Line length: 120 characters -- Python type hints -- Docstrings for public methods +![Line 120](https://img.shields.io/badge/line%20120-lightblue?logo=prettier&logoColor=black) +![Types](https://img.shields.io/badge/typed-pink?logo=python&logoColor=black) +![Docs](https://img.shields.io/badge/documented-lightcyan?logo=markdown&logoColor=black) -### 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`: @@ -103,3 +125,42 @@ query GetShout($slug: String) { } } ``` + +--- + +## 📊 Project Stats + +
+ +![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) + +
+ +## 🤝 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) + +--- + +
+ +**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) + +
diff --git a/docs/README.md b/docs/README.md index c0f6f1be..3429ecec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,7 @@ python dev.py - [API методы](api.md) - GraphQL эндпоинты - [Функции системы](features.md) - Полный список возможностей -## ⚡ Ключевые возможности (v0.5.4) +## ⚡ Ключевые возможности ### Авторизация - **Модульная архитектура**: SessionTokenManager, VerificationTokenManager, OAuthTokenManager diff --git a/docs/features.md b/docs/features.md index 682a3b57..d22e286a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -95,3 +95,22 @@ - **Дополнительные мутации**: - confirmEmailChange - cancelEmailChange + +## Система featured публикаций + +- **Автоматическое получение статуса featured**: + - Публикация получает статус featured при более чем 4 лайках от авторов с featured статьями + - Проверка квалификации автора: наличие опубликованных featured статей + - Логирование процесса для отладки и мониторинга +- **Условия удаления с главной (unfeatured)**: + - **Условие 1**: Менее 5 голосов "за" (положительные реакции) + - **Условие 2**: 20% или более отрицательных реакций от общего количества голосов + - Проверка выполняется только для уже featured публикаций +- **Оптимизированная логика обработки**: + - Проверка unfeatured имеет приоритет над featured при обработке реакций + - Автоматическая проверка условий при добавлении/удалении реакций + - Корректная обработка типов данных в функциях проверки +- **Интеграция с системой реакций**: + - Обработка в `create_reaction` для новых реакций + - Обработка в `delete_reaction` для удаленных реакций + - Учет только реакций на саму публикацию (не на комментарии) diff --git a/requirements.txt b/requirements.txt index 8e452a2a..ecd287d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ bcrypt PyJWT authlib passlib==1.7.4 -opensearch-py google-analytics-data colorlog psycopg2-binary diff --git a/resolvers/reaction.py b/resolvers/reaction.py index 355431e7..f4af7ca0 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -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: """ - 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 reaction: Reaction object. :return: True if shout should be unfeatured, else False. """ if not reaction.get("reply_to"): + shout_id = reaction.get("shout") + # Проверяем соотношение дизлайков, даже если текущая реакция не дизлайк total_reactions = ( session.query(Reaction) .filter( - Reaction.shout == reaction.get("shout"), + Reaction.shout == shout_id, Reaction.reply_to.is_(None), Reaction.kind.in_(RATING_REACTIONS), # Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен @@ -186,10 +190,21 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool: .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 = ( session.query(Reaction) .filter( - Reaction.shout == reaction.get("shout"), + Reaction.shout == shout_id, is_negative(Reaction.kind), Reaction.reply_to.is_(None), # Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен @@ -197,12 +212,19 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool: .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 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 @@ -265,12 +287,16 @@ async def _create_reaction(session: Session, shout_id: int, is_author: bool, aut # Handle rating if r.kind in RATING_REACTIONS: - # Проверяем сначала условие для unfeature (дизлайки имеют приоритет) - if check_to_unfeature(session, rdict): + # Проверяем, является ли публикация featured + 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) - logger.info(f"Публикация {shout_id} потеряла статус featured из-за высокого процента дизлайков") + logger.info(f"Публикация {shout_id} потеряла статус featured из-за условий unfeaturing") # Только если не было 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) 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 else: 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.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) reaction_dict = r.dict()