import logging import os.path from django.http.response import HttpResponse from django_rest_validr import RestRouter, T from rest_framework.response import Response from xml.sax.saxutils import escape as xml_escape from xml.sax.saxutils import quoteattr as xml_quote from mako.template import Template from rssant_feedlib.importer import import_feed_from_text from rssant_api.models.errors import FeedExistError, FeedStoryOffsetError from rssant_api.models.errors import FeedNotFoundError from rssant_api.models.feed import FeedDetailSchema from rssant_api.models import UnionFeed, FeedCreation from rssant.settings import BASE_DIR from rssant_common.actor_client import scheduler from rssant_common.helper import timer from .helper import check_unionid from .errors import RssantAPIException OPML_TEMPLATE_PATH = os.path.join(BASE_DIR, 'rssant_api', 'resources', 'opml.mako') LOG = logging.getLogger(__name__) FeedSchema = T.dict( id=T.feed_unionid, user=T.dict( id=T.int, ), status=T.str, url=T.url, link=T.str.optional, author=T.str.optional, icon=T.str.optional, description=T.str.optional, version=T.str.optional, title=T.str.optional, warnings=T.str.optional, num_unread_storys=T.int.optional, total_storys=T.int.optional, dt_updated=T.datetime.object.optional, dt_created=T.datetime.object.optional, dt_checked=T.datetime.object.optional, dt_synced=T.datetime.object.optional, encoding=T.str.optional, etag=T.str.optional, last_modified=T.str.optional, content_hash_base64=T.str.optional, story_offset=T.int.min(0).optional, dryness=T.int.min(0).max(1000).optional, freeze_level=T.int.min(0).optional, use_proxy=T.bool.optional, response_status=T.int.optional, dt_first_story_published=T.datetime.object.optional.invalid_to_default, dt_latest_story_published=T.datetime.object.optional.invalid_to_default, ).slim FeedCreationSchema = T.dict( id=T.int, user=T.dict( id=T.int, ), is_ready=T.bool, feed_id=T.feed_unionid.optional, status=T.str, url=T.url, message=T.str.optional, dt_updated=T.datetime.object.optional, dt_created=T.datetime.object.optional, ) FeedView = RestRouter() @FeedView.get('feed/query') @FeedView.post('feed/query') def feed_query( request, hints: T.list(T.dict(id=T.feed_unionid.object, dt_updated=T.datetime.object)).maxlen(5000).optional, detail: FeedDetailSchema, ) -> T.dict( total=T.int.optional, size=T.int.optional, feeds=T.list(FeedSchema).maxlen(5000), deleted_size=T.int.optional, deleted_ids=T.list(T.feed_unionid), ): """Feed query""" if hints: check_unionid(request, [x['id'] for x in hints]) total, feeds, deleted_ids = UnionFeed.query_by_user( user_id=request.user.id, hints=hints, detail=detail) feeds = [x.to_dict() for x in feeds] return dict( total=total, size=len(feeds), feeds=feeds, deleted_size=len(deleted_ids), deleted_ids=deleted_ids, ) @FeedView.get('feed/<slug:feed_unionid>') def feed_get(request, feed_unionid: T.feed_unionid.object, detail: FeedDetailSchema) -> FeedSchema: """Feed detail""" check_unionid(request, feed_unionid) try: feed = UnionFeed.get_by_id(feed_unionid, detail=detail) except FeedNotFoundError: return Response({"message": "订阅不存在"}, status=400) return feed.to_dict() @FeedView.post('feed/creation') def feed_create(request, url: T.url.default_schema('http')) -> T.dict( is_ready=T.bool, feed=FeedSchema.optional, feed_creation=FeedCreationSchema.optional, ): try: feed, feed_creation = UnionFeed.create_by_url(url=url, user_id=request.user.id) except FeedExistError: return Response({'message': 'already exists'}, status=400) if feed_creation: scheduler.tell('worker_rss.find_feed', dict( feed_creation_id=feed_creation.id, url=feed_creation.url, )) return dict( is_ready=bool(feed), feed=feed.to_dict() if feed else None, feed_creation=feed_creation.to_dict() if feed_creation else None, ) @FeedView.get('feed/creation/<int:pk>') def feed_get_creation(request, pk: T.int, detail: FeedDetailSchema) -> FeedCreationSchema: try: feed_creation = FeedCreation.get_by_pk(pk, user_id=request.user.id, detail=detail) except FeedCreation.DoesNotExist: return Response({'message': 'feed creation does not exist'}, status=400) return feed_creation.to_dict(detail=detail) @FeedView.get('feed/creation') def feed_query_creation( request, limit: T.int.min(10).max(2000).default(500), detail: FeedDetailSchema ) -> T.dict( total=T.int.min(0), size=T.int.min(0), feed_creations=T.list(FeedCreationSchema).maxlen(2000), ): feed_creations = FeedCreation.query_by_user(request.user.id, limit=limit, detail=detail) feed_creations = [x.to_dict() for x in feed_creations] return dict( total=len(feed_creations), size=len(feed_creations), feed_creations=feed_creations, ) @FeedView.put('feed/<slug:feed_unionid>') def feed_update(request, feed_unionid: T.feed_unionid.object, title: T.str.optional) -> FeedSchema: check_unionid(request, feed_unionid) feed = UnionFeed.set_title(feed_unionid, title) return feed.to_dict() @FeedView.put('feed/<slug:feed_unionid>/offset') def feed_set_offset(request, feed_unionid: T.feed_unionid.object, offset: T.int.min(0).optional) -> FeedSchema: check_unionid(request, feed_unionid) try: feed = UnionFeed.set_story_offset(feed_unionid, offset) except FeedStoryOffsetError as ex: return Response({'message': str(ex)}, status=400) return feed.to_dict() @FeedView.put('feed/all/readed') def feed_set_all_readed(request, ids: T.list(T.feed_unionid.object).optional) -> T.dict(num_updated=T.int): check_unionid(request, ids) num_updated = UnionFeed.set_all_readed_by_user(user_id=request.user.id, ids=ids) return dict(num_updated=num_updated) @FeedView.delete('feed/<slug:feed_unionid>') def feed_delete(request, feed_unionid: T.feed_unionid.object): check_unionid(request, feed_unionid) try: UnionFeed.delete_by_id(feed_unionid) except FeedNotFoundError: return Response({"message": "订阅不存在"}, status=400) @FeedView.post('feed/all/delete') def feed_delete_all(request, ids: T.list(T.feed_unionid.object).optional) -> T.dict(num_deleted=T.int): check_unionid(request, ids) num_deleted = UnionFeed.delete_all(user_id=request.user.id, ids=ids) return dict(num_deleted=num_deleted) def _read_request_file(request, name='file'): fileobj = request.FILES.get(name) if not fileobj: raise RssantAPIException('file not received') text = fileobj.read() if not isinstance(text, str): try: text = text.decode('utf-8') except UnicodeDecodeError: raise RssantAPIException('file type or encoding invalid') return text, fileobj.name def _create_feeds_by_urls(user, urls, is_from_bookmark=False): result = UnionFeed.create_by_url_s(urls=urls, user_id=user.id) find_feed_tasks = [] for feed_creation in result.feed_creations: find_feed_tasks.append(dict( dst='worker_rss.find_feed', content=dict( feed_creation_id=feed_creation.id, url=feed_creation.url, ) )) scheduler.batch_tell(find_feed_tasks) created_feeds = [x.to_dict() for x in result.created_feeds] feed_creations = [x.to_dict() for x in result.feed_creations] return dict( total=result.total, num_created_feeds=len(result.created_feeds), num_existed_feeds=len(result.existed_feeds), num_feed_creations=len(result.feed_creations), created_feeds=created_feeds, feed_creations=feed_creations, ) FeedImportResultSchema = T.dict( total=T.int.min(0), num_created_feeds=T.int.min(0), num_existed_feeds=T.int.min(0), num_feed_creations=T.int.min(0), created_feeds=T.list(FeedSchema).maxlen(5000), feed_creations=T.list(FeedCreationSchema).maxlen(5000), ) @FeedView.post('feed/opml') def feed_import_opml(request) -> FeedImportResultSchema: """import feeds from OPML file""" return feed_import_file(request) @FeedView.get('feed/opml') @FeedView.get('feed/export/opml') def feed_export_opml(request, download: T.bool.default(False)): """export feeds to OPML file""" total, feeds, __ = UnionFeed.query_by_user(request.user.id) feeds = [x.to_dict() for x in feeds] for user_feed in feeds: for field in ['title', 'link', 'url', 'version']: user_feed[field] = xml_quote(xml_escape(user_feed[field] or '')) tmpl = Template(filename=OPML_TEMPLATE_PATH) content = tmpl.render(feeds=feeds) response = HttpResponse(content, content_type='text/xml') if download: response['Content-Disposition'] = 'attachment;filename="rssant.opml"' return response @FeedView.post('feed/bookmark') def feed_import_bookmark(request) -> FeedImportResultSchema: """import feeds from bookmark file""" return feed_import_file(request) @FeedView.post('feed/import') def feed_import(request, text: T.str) -> FeedImportResultSchema: """从OPML/XML内容或含有链接的HTML或文本内容导入订阅""" with timer('Import-Feed-From-Text'): urls = import_feed_from_text(text) if len(urls) > 2000: return Response({"message": "订阅数超过限制"}, status=400) is_from_bookmark = len(urls) > 100 return _create_feeds_by_urls(request.user, urls, is_from_bookmark=is_from_bookmark) @FeedView.post('feed/import/file') def feed_import_file(request) -> FeedImportResultSchema: """从OPML/XML/浏览器书签/含有链接的HTML或文本文件导入订阅""" text, filename = _read_request_file(request) return feed_import(request, text)