diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5f65e60da..5dfbf0642 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,13 +3,39 @@ variables: IMAGE: $IMAGE_NAME:$CI_COMMIT_REF_NAME IMAGE_LATEST: $IMAGE_NAME:latest PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache" + PYTHONDONTWRITEBYTECODE: "true" stages: + - lint - test - build - deploy +black: + image: python:3.6 + stage: lint + variables: + GIT_STRATEGY: fetch + before_script: + - pip install black + script: + - black --check --diff api/ + +flake8: + image: python:3.6 + stage: lint + variables: + GIT_STRATEGY: fetch + before_script: + - pip install flake8 + script: + - flake8 -v api + cache: + key: "$CI_PROJECT_ID__flake8_pip_cache" + paths: + - "$PIP_CACHE_DIR" + test_api: services: - postgres:9.4 @@ -108,7 +134,7 @@ pages: tags: - docker -docker_develop: +docker_release: stage: deploy before_script: - docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD @@ -119,8 +145,9 @@ docker_develop: - docker push $IMAGE only: - develop@funkwhale/funkwhale + - tags@funkwhale/funkwhale tags: - - dind + - docker-build build_api: # Simply publish a zip containing api/ directory @@ -135,19 +162,3 @@ build_api: - tags@funkwhale/funkwhale - master@funkwhale/funkwhale - develop@funkwhale/funkwhale - - -docker_release: - stage: deploy - before_script: - - docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD - - cp -r front/dist api/frontend - - cd api - script: - - docker build -t $IMAGE -t $IMAGE_LATEST . - - docker push $IMAGE - - docker push $IMAGE_LATEST - only: - - tags@funkwhale/funkwhale - tags: - - dind diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md new file mode 100644 index 000000000..967186030 --- /dev/null +++ b/.gitlab/issue_templates/Bug.md @@ -0,0 +1,43 @@ + + +/label ~"Type: Bug" ~"Status: Need triage" + +## Steps to reproduce + + + +## What happens? + + + +## What is expected? + + + +## Context + + diff --git a/.gitlab/issue_templates/Feature request.md b/.gitlab/issue_templates/Feature request.md new file mode 100644 index 000000000..404f9c9de --- /dev/null +++ b/.gitlab/issue_templates/Feature request.md @@ -0,0 +1,39 @@ + + +/label ~"Type: New feature" ~"Status: Need triage" + +## What is the problem you are facing? + + + +## What are the possible drawbacks or issues with the requested changes? + + + +## Context + + diff --git a/CHANGELOG b/CHANGELOG index edff0877e..ee59b7f20 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,133 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog. .. towncrier +0.14.2 (2018-06-16) +------------------- + +.. warning:: + + This release contains a fix for a permission issue. You should upgrade + as soon as possible. Read the changelog below for more details. + +Upgrade instructions are available at +https://docs.funkwhale.audio/upgrading.html + +Enhancements: + +- Added feedback on shuffle button (#262) +- Added multiple warnings in the documentation that you should never run + makemigrations yourself (#291) +- Album cover served in http (#264) +- Apache2 reverse proxy now supports websockets (tested with Apache 2.4.25) + (!252) +- Display file size in human format during file upload (#289) +- Switch from BSD-3 licence to AGPL-3 licence (#280) + +Bugfixes: + +- Ensure radios can only be edited and deleted by their owners (#311) +- Fixed admin menu not showing after login (#245) +- Fixed broken pagination in Subsonic API (#295) +- Fixed duplicated websocket connexion on timeline (#287) + + +Documentation: + +- Improved documentation about in-place imports setup (#298) + + +Other: + +- Added Black and flake8 checks in CI to ensure consistent code styling and + formatting (#297) +- Added bug and feature issue templates (#299) + + +Permission issues on radios +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Because of an error in the way we checked user permissions on radios, +public radios could be deleted by any logged-in user, even if they were not +the owner of the radio. + +We recommend instances owners to upgrade as fast as possible to avoid any abuse +and data loss. + + +Funkwhale is now licenced under AGPL-3 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Following the recent switch made by PixelFed +(https://github.com/dansup/pixelfed/issues/143), we decided along with +the community to relicence Funkwhale under the AGPL-3 licence. We did this +switch for various reasons: + +- This is better aligned with other fediverse software +- It prohibits anyone to distribute closed-source and proprietary forks of Funkwhale + +As end users and instance owners, this does not change anything. You can +continue to use Funkwhale exactly as you did before :) + + +Apache support for websocket +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Up until now, our Apache2 configuration was not working with websockets. This is now +solved by adding this at the beginning of your Apache2 configuration file:: + + Define funkwhale-api-ws ws://localhost:5000 + +And this, before the "/api" block:: + + # Activating WebSockets + ProxyPass "/api/v1/instance/activity" ${funkwhale-api-ws}/api/v1/instance/activity + +Websockets may not be supported in older versions of Apache2. Be sure to upgrade to the latest version available. + + +Serving album covers in https (Apache2 proxy) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Two issues are addressed here. The first one was about Django replying with +mixed content (http) when queried for covers. Setting up the `X-Forwarded-Proto` +allows Django to know that the client is using https, and that the reply must +be https as well. + +Second issue was a problem of permission causing Apache a denied access to +album cover folder. It is solved by adding another block for this path in +the Apache configuration file for funkwhale. + +Here is how to modify your `funkwhale.conf` apache2 configuration:: + + + + ... + #Add this new line + RequestHeader set X-Forwarded-Proto "https" + ... + # Add this new block below the other blocks + # replace /srv/funkwhale/data/media with the path to your media directory + # if you're not using the standard layout. + + Options FollowSymLinks + AllowOverride None + Require all granted + + ... + + + +About the makemigrations warning +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +You may sometimes get the following warning while applying migrations:: + + "Your models have changes that are not yet reflected in a migration, and so won't be applied." + +This is a warning, not an error, and it can be safely ignored. +Never run the ``makemigrations`` command yourself. + + 0.14.1 (2018-06-06) ------------------- diff --git a/CONTRIBUTING b/CONTRIBUTING index 9f4ec8850..f79512def 100644 --- a/CONTRIBUTING +++ b/CONTRIBUTING @@ -61,16 +61,6 @@ If you do not want to add the ``-f dev.yml`` snippet everytime, you can run this export COMPOSE_FILE=dev.yml -Building the containers -^^^^^^^^^^^^^^^^^^^^^^^ - -On your initial clone, or if there have been some changes in the -app dependencies, you will have to rebuild your containers. This is done -via the following command:: - - docker-compose -f dev.yml build - - Creating your env file ^^^^^^^^^^^^^^^^^^^^^^ @@ -84,6 +74,24 @@ Create it like this:: touch .env +Create docker network +^^^^^^^^^^^^^^^^^^^^ + +Create the federation network:: + + docker network create federation + + +Building the containers +^^^^^^^^^^^^^^^^^^^^^^^ + +On your initial clone, or if there have been some changes in the +app dependencies, you will have to rebuild your containers. This is done +via the following command:: + + docker-compose -f dev.yml build + + Database management ^^^^^^^^^^^^^^^^^^^ @@ -124,7 +132,7 @@ Launch all services Then you can run everything with:: - docker-compose -f dev.yml up + docker-compose -f dev.yml up front api nginx celeryworker This will launch all services, and output the logs in your current terminal window. If you prefer to launch them in the background instead, use the ``-d`` flag, and access the logs when you need it via ``docker-compose -f dev.yml logs --tail=50 --follow``. @@ -194,13 +202,6 @@ Run a reverse proxy for your instances ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Create docker network -^^^^^^^^^^^^^^^^^^^^ - -Create the federation network:: - - docker network create federation - Launch everything ^^^^^^^^^^^^^^^^^ diff --git a/LICENSE b/LICENSE index e30bee823..dba13ed2d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,27 +1,661 @@ -Copyright (c) 2015, Eliot Berriot -All rights reserved. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + Preamble -* Redistributions in binary form must reproduce the above copyright notice, this - list of conditions and the following disclaimer in the documentation and/or - other materials provided with the distribution. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. -* Neither the name of funkwhale_api nor the names of its - contributors may be used to endorse or promote products derived from this - software without specific prior written permission. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, -INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, -BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY -OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE -OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED -OF THE POSSIBILITY OF SUCH DAMAGE. + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.rst b/README.rst index 8646527ad..ef3998d11 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ Funkwhale A self-hosted tribute to Grooveshark.com. -LICENSE: BSD +LICENSE: AGPL3 Getting help ------------ diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 98b863a93..9f87a7af3 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -1,81 +1,79 @@ +from django.conf.urls import include, url +from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet from rest_framework import routers from rest_framework.urlpatterns import format_suffix_patterns -from django.conf.urls import include, url +from rest_framework_jwt import views as jwt_views + from funkwhale_api.activity import views as activity_views -from funkwhale_api.instance import views as instance_views from funkwhale_api.music import views from funkwhale_api.playlists import views as playlists_views from funkwhale_api.subsonic.views import SubsonicViewSet -from rest_framework_jwt import views as jwt_views - -from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet -from dynamic_preferences.users.viewsets import UserPreferencesViewSet router = routers.SimpleRouter() -router.register(r'settings', GlobalPreferencesViewSet, base_name='settings') -router.register(r'activity', activity_views.ActivityViewSet, 'activity') -router.register(r'tags', views.TagViewSet, 'tags') -router.register(r'tracks', views.TrackViewSet, 'tracks') -router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles') -router.register(r'artists', views.ArtistViewSet, 'artists') -router.register(r'albums', views.AlbumViewSet, 'albums') -router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches') -router.register(r'import-jobs', views.ImportJobViewSet, 'import-jobs') -router.register(r'submit', views.SubmitViewSet, 'submit') -router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists') +router.register(r"settings", GlobalPreferencesViewSet, base_name="settings") +router.register(r"activity", activity_views.ActivityViewSet, "activity") +router.register(r"tags", views.TagViewSet, "tags") +router.register(r"tracks", views.TrackViewSet, "tracks") +router.register(r"trackfiles", views.TrackFileViewSet, "trackfiles") +router.register(r"artists", views.ArtistViewSet, "artists") +router.register(r"albums", views.AlbumViewSet, "albums") +router.register(r"import-batches", views.ImportBatchViewSet, "import-batches") +router.register(r"import-jobs", views.ImportJobViewSet, "import-jobs") +router.register(r"submit", views.SubmitViewSet, "submit") +router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists") router.register( - r'playlist-tracks', - playlists_views.PlaylistTrackViewSet, - 'playlist-tracks') + r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks" +) v1_patterns = router.urls subsonic_router = routers.SimpleRouter(trailing_slash=False) -subsonic_router.register(r'subsonic/rest', SubsonicViewSet, base_name='subsonic') +subsonic_router.register(r"subsonic/rest", SubsonicViewSet, base_name="subsonic") v1_patterns += [ - url(r'^instance/', + url( + r"^instance/", + include(("funkwhale_api.instance.urls", "instance"), namespace="instance"), + ), + url( + r"^manage/", + include(("funkwhale_api.manage.urls", "manage"), namespace="manage"), + ), + url( + r"^federation/", include( - ('funkwhale_api.instance.urls', 'instance'), - namespace='instance')), - url(r'^manage/', - include( - ('funkwhale_api.manage.urls', 'manage'), - namespace='manage')), - url(r'^federation/', - include( - ('funkwhale_api.federation.api_urls', 'federation'), - namespace='federation')), - url(r'^providers/', - include( - ('funkwhale_api.providers.urls', 'providers'), - namespace='providers')), - url(r'^favorites/', - include( - ('funkwhale_api.favorites.urls', 'favorites'), - namespace='favorites')), - url(r'^search$', - views.Search.as_view(), name='search'), - url(r'^radios/', - include( - ('funkwhale_api.radios.urls', 'radios'), - namespace='radios')), - url(r'^history/', - include( - ('funkwhale_api.history.urls', 'history'), - namespace='history')), - url(r'^users/', - include( - ('funkwhale_api.users.api_urls', 'users'), - namespace='users')), - url(r'^requests/', - include( - ('funkwhale_api.requests.api_urls', 'requests'), - namespace='requests')), - url(r'^token/$', jwt_views.obtain_jwt_token, name='token'), - url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'), + ("funkwhale_api.federation.api_urls", "federation"), namespace="federation" + ), + ), + url( + r"^providers/", + include(("funkwhale_api.providers.urls", "providers"), namespace="providers"), + ), + url( + r"^favorites/", + include(("funkwhale_api.favorites.urls", "favorites"), namespace="favorites"), + ), + url(r"^search$", views.Search.as_view(), name="search"), + url( + r"^radios/", + include(("funkwhale_api.radios.urls", "radios"), namespace="radios"), + ), + url( + r"^history/", + include(("funkwhale_api.history.urls", "history"), namespace="history"), + ), + url( + r"^users/", + include(("funkwhale_api.users.api_urls", "users"), namespace="users"), + ), + url( + r"^requests/", + include(("funkwhale_api.requests.api_urls", "requests"), namespace="requests"), + ), + url(r"^token/$", jwt_views.obtain_jwt_token, name="token"), + url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"), ] urlpatterns = [ - url(r'^v1/', include((v1_patterns, 'v1'), namespace='v1')) -] + format_suffix_patterns(subsonic_router.urls, allowed=['view']) + url(r"^v1/", include((v1_patterns, "v1"), namespace="v1")) +] + format_suffix_patterns(subsonic_router.urls, allowed=["view"]) diff --git a/api/config/asgi.py b/api/config/asgi.py index b976a02eb..886178cc2 100644 --- a/api/config/asgi.py +++ b/api/config/asgi.py @@ -1,8 +1,9 @@ -import django import os -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") +import django django.setup() -from .routing import application +from .routing import application # noqa + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") diff --git a/api/config/routing.py b/api/config/routing.py index 574d5a18e..fa25aad07 100644 --- a/api/config/routing.py +++ b/api/config/routing.py @@ -1,18 +1,16 @@ -from django.conf.urls import url - -from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter +from django.conf.urls import url from funkwhale_api.common.auth import TokenAuthMiddleware from funkwhale_api.instance import consumers - -application = ProtocolTypeRouter({ - # Empty for now (http->django views is added by default) - "websocket": TokenAuthMiddleware( - URLRouter([ - url("^api/v1/instance/activity$", - consumers.InstanceActivityConsumer), - ]) - ), -}) +application = ProtocolTypeRouter( + { + # Empty for now (http->django views is added by default) + "websocket": TokenAuthMiddleware( + URLRouter( + [url("^api/v1/instance/activity$", consumers.InstanceActivityConsumer)] + ) + ) + } +) diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 6ab2a8303..cb5573ed5 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -10,131 +10,125 @@ https://docs.djangoproject.com/en/dev/ref/settings/ """ from __future__ import absolute_import, unicode_literals -from urllib.parse import urlsplit -import os +import datetime +from urllib.parse import urlparse, urlsplit + import environ from celery.schedules import crontab from funkwhale_api import __version__ ROOT_DIR = environ.Path(__file__) - 3 # (/a/b/myfile.py - 3 = /) -APPS_DIR = ROOT_DIR.path('funkwhale_api') +APPS_DIR = ROOT_DIR.path("funkwhale_api") env = environ.Env() - try: - env.read_env(ROOT_DIR.file('.env')) + env.read_env(ROOT_DIR.file(".env")) except FileNotFoundError: pass FUNKWHALE_HOSTNAME = None -FUNKWHALE_HOSTNAME_SUFFIX = env('FUNKWHALE_HOSTNAME_SUFFIX', default=None) -FUNKWHALE_HOSTNAME_PREFIX = env('FUNKWHALE_HOSTNAME_PREFIX', default=None) +FUNKWHALE_HOSTNAME_SUFFIX = env("FUNKWHALE_HOSTNAME_SUFFIX", default=None) +FUNKWHALE_HOSTNAME_PREFIX = env("FUNKWHALE_HOSTNAME_PREFIX", default=None) if FUNKWHALE_HOSTNAME_PREFIX and FUNKWHALE_HOSTNAME_SUFFIX: # We're in traefik case, in development - FUNKWHALE_HOSTNAME = '{}.{}'.format( - FUNKWHALE_HOSTNAME_PREFIX, FUNKWHALE_HOSTNAME_SUFFIX) - FUNKWHALE_PROTOCOL = env('FUNKWHALE_PROTOCOL', default='https') + FUNKWHALE_HOSTNAME = "{}.{}".format( + FUNKWHALE_HOSTNAME_PREFIX, FUNKWHALE_HOSTNAME_SUFFIX + ) + FUNKWHALE_PROTOCOL = env("FUNKWHALE_PROTOCOL", default="https") else: try: - FUNKWHALE_HOSTNAME = env('FUNKWHALE_HOSTNAME') - FUNKWHALE_PROTOCOL = env('FUNKWHALE_PROTOCOL', default='https') + FUNKWHALE_HOSTNAME = env("FUNKWHALE_HOSTNAME") + FUNKWHALE_PROTOCOL = env("FUNKWHALE_PROTOCOL", default="https") except Exception: - FUNKWHALE_URL = env('FUNKWHALE_URL') + FUNKWHALE_URL = env("FUNKWHALE_URL") _parsed = urlsplit(FUNKWHALE_URL) FUNKWHALE_HOSTNAME = _parsed.netloc FUNKWHALE_PROTOCOL = _parsed.scheme -FUNKWHALE_URL = '{}://{}'.format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME) +FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME) # XXX: deprecated, see #186 -FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True) -FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME) +FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True) +FEDERATION_HOSTNAME = env("FEDERATION_HOSTNAME", default=FUNKWHALE_HOSTNAME) # XXX: deprecated, see #186 -FEDERATION_COLLECTION_PAGE_SIZE = env.int( - 'FEDERATION_COLLECTION_PAGE_SIZE', default=50 -) +FEDERATION_COLLECTION_PAGE_SIZE = env.int("FEDERATION_COLLECTION_PAGE_SIZE", default=50) # XXX: deprecated, see #186 FEDERATION_MUSIC_NEEDS_APPROVAL = env.bool( - 'FEDERATION_MUSIC_NEEDS_APPROVAL', default=True + "FEDERATION_MUSIC_NEEDS_APPROVAL", default=True ) # XXX: deprecated, see #186 -FEDERATION_ACTOR_FETCH_DELAY = env.int( - 'FEDERATION_ACTOR_FETCH_DELAY', default=60 * 12) -ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') +FEDERATION_ACTOR_FETCH_DELAY = env.int("FEDERATION_ACTOR_FETCH_DELAY", default=60 * 12) +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS") # APP CONFIGURATION # ------------------------------------------------------------------------------ DJANGO_APPS = ( - 'channels', + "channels", # Default Django apps: - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.postgres', - + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.postgres", # Useful template tags: # 'django.contrib.humanize', - # Admin - 'django.contrib.admin', + "django.contrib.admin", ) THIRD_PARTY_APPS = ( # 'crispy_forms', # Form layouts - 'allauth', # registration - 'allauth.account', # registration - 'allauth.socialaccount', # registration - 'corsheaders', - 'rest_framework', - 'rest_framework.authtoken', - 'taggit', - 'rest_auth', - 'rest_auth.registration', - 'dynamic_preferences', - 'django_filters', - 'cacheops', - 'django_cleanup', + "allauth", # registration + "allauth.account", # registration + "allauth.socialaccount", # registration + "corsheaders", + "rest_framework", + "rest_framework.authtoken", + "taggit", + "rest_auth", + "rest_auth.registration", + "dynamic_preferences", + "django_filters", + "cacheops", + "django_cleanup", ) # Sentry RAVEN_ENABLED = env.bool("RAVEN_ENABLED", default=False) -RAVEN_DSN = env("RAVEN_DSN", default='') +RAVEN_DSN = env("RAVEN_DSN", default="") if RAVEN_ENABLED: RAVEN_CONFIG = { - 'dsn': RAVEN_DSN, + "dsn": RAVEN_DSN, # If you are using git, you can also automatically configure the # release based on the git info. - 'release': __version__, + "release": __version__, } - THIRD_PARTY_APPS += ( - 'raven.contrib.django.raven_compat', - ) + THIRD_PARTY_APPS += ("raven.contrib.django.raven_compat",) # Apps specific for this project go here. LOCAL_APPS = ( - 'funkwhale_api.common', - 'funkwhale_api.activity.apps.ActivityConfig', - 'funkwhale_api.users', # custom users app + "funkwhale_api.common", + "funkwhale_api.activity.apps.ActivityConfig", + "funkwhale_api.users", # custom users app # Your stuff: custom apps go here - 'funkwhale_api.instance', - 'funkwhale_api.music', - 'funkwhale_api.requests', - 'funkwhale_api.favorites', - 'funkwhale_api.federation', - 'funkwhale_api.radios', - 'funkwhale_api.history', - 'funkwhale_api.playlists', - 'funkwhale_api.providers.audiofile', - 'funkwhale_api.providers.youtube', - 'funkwhale_api.providers.acoustid', - 'funkwhale_api.subsonic', + "funkwhale_api.instance", + "funkwhale_api.music", + "funkwhale_api.requests", + "funkwhale_api.favorites", + "funkwhale_api.federation", + "funkwhale_api.radios", + "funkwhale_api.history", + "funkwhale_api.playlists", + "funkwhale_api.providers.audiofile", + "funkwhale_api.providers.youtube", + "funkwhale_api.providers.acoustid", + "funkwhale_api.subsonic", ) # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -145,20 +139,18 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS # ------------------------------------------------------------------------------ MIDDLEWARE = ( # Make sure djangosecure.middleware.SecurityMiddleware is listed first - 'django.contrib.sessions.middleware.SessionMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ) # MIGRATIONS CONFIGURATION # ------------------------------------------------------------------------------ -MIGRATION_MODULES = { - 'sites': 'funkwhale_api.contrib.sites.migrations' -} +MIGRATION_MODULES = {"sites": "funkwhale_api.contrib.sites.migrations"} # DEBUG # ------------------------------------------------------------------------------ @@ -168,9 +160,7 @@ DEBUG = env.bool("DJANGO_DEBUG", False) # FIXTURE CONFIGURATION # ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-FIXTURE_DIRS -FIXTURE_DIRS = ( - str(APPS_DIR.path('fixtures')), -) +FIXTURE_DIRS = (str(APPS_DIR.path("fixtures")),) # EMAIL CONFIGURATION # ------------------------------------------------------------------------------ @@ -178,16 +168,14 @@ FIXTURE_DIRS = ( # EMAIL # ------------------------------------------------------------------------------ DEFAULT_FROM_EMAIL = env( - 'DEFAULT_FROM_EMAIL', - default='Funkwhale '.format(FUNKWHALE_HOSTNAME)) + "DEFAULT_FROM_EMAIL", default="Funkwhale ".format(FUNKWHALE_HOSTNAME) +) -EMAIL_SUBJECT_PREFIX = env( - "EMAIL_SUBJECT_PREFIX", default='[Funkwhale] ') -SERVER_EMAIL = env('SERVER_EMAIL', default=DEFAULT_FROM_EMAIL) +EMAIL_SUBJECT_PREFIX = env("EMAIL_SUBJECT_PREFIX", default="[Funkwhale] ") +SERVER_EMAIL = env("SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) -EMAIL_CONFIG = env.email_url( - 'EMAIL_CONFIG', default='consolemail://') +EMAIL_CONFIG = env.email_url("EMAIL_CONFIG", default="consolemail://") vars().update(EMAIL_CONFIG) @@ -196,9 +184,9 @@ vars().update(EMAIL_CONFIG) # See: https://docs.djangoproject.com/en/dev/ref/settings/#databases DATABASES = { # Raises ImproperlyConfigured exception if DATABASE_URL not in os.environ - 'default': env.db("DATABASE_URL"), + "default": env.db("DATABASE_URL") } -DATABASES['default']['ATOMIC_REQUESTS'] = True +DATABASES["default"]["ATOMIC_REQUESTS"] = True # # DATABASES = { # 'default': { @@ -212,10 +200,10 @@ DATABASES['default']['ATOMIC_REQUESTS'] = True # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. # In a Windows environment this must be set to your system time zone. -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" # See: https://docs.djangoproject.com/en/dev/ref/settings/#language-code -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" # See: https://docs.djangoproject.com/en/dev/ref/settings/#site-id SITE_ID = 1 @@ -235,152 +223,142 @@ USE_TZ = True TEMPLATES = [ { # See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND - 'BACKEND': 'django.template.backends.django.DjangoTemplates', + "BACKEND": "django.template.backends.django.DjangoTemplates", # See: https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs - 'DIRS': [ - str(APPS_DIR.path('templates')), - ], - 'OPTIONS': { + "DIRS": [str(APPS_DIR.path("templates"))], + "OPTIONS": { # See: https://docs.djangoproject.com/en/dev/ref/settings/#template-debug - 'debug': DEBUG, + "debug": DEBUG, # See: https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types - 'loaders': [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", ], # See: https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", # Your stuff: custom template context processors go here ], }, - }, + } ] # See: http://django-crispy-forms.readthedocs.org/en/latest/install.html#template-packs -CRISPY_TEMPLATE_PACK = 'bootstrap3' +CRISPY_TEMPLATE_PACK = "bootstrap3" # STATIC FILE CONFIGURATION # ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/dev/ref/settings/#static-root -STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR('staticfiles'))) +STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR("staticfiles"))) # See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url -STATIC_URL = env("STATIC_URL", default='/staticfiles/') -DEFAULT_FILE_STORAGE = 'funkwhale_api.common.storage.ASCIIFileSystemStorage' +STATIC_URL = env("STATIC_URL", default="/staticfiles/") +DEFAULT_FILE_STORAGE = "funkwhale_api.common.storage.ASCIIFileSystemStorage" # See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS -STATICFILES_DIRS = ( - str(APPS_DIR.path('static')), -) +STATICFILES_DIRS = (str(APPS_DIR.path("static")),) # See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", ) # MEDIA CONFIGURATION # ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-root -MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR('media'))) +MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR("media"))) # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url -MEDIA_URL = env("MEDIA_URL", default='/media/') +MEDIA_URL = env("MEDIA_URL", default="/media/") # URL Configuration # ------------------------------------------------------------------------------ -ROOT_URLCONF = 'config.urls' +ROOT_URLCONF = "config.urls" # See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application -WSGI_APPLICATION = 'config.wsgi.application' +WSGI_APPLICATION = "config.wsgi.application" ASGI_APPLICATION = "config.routing.application" # This ensures that Django will be able to detect a secure connection -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # AUTHENTICATION CONFIGURATION # ------------------------------------------------------------------------------ AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", ) SESSION_COOKIE_HTTPONLY = False # Some really nice defaults -ACCOUNT_AUTHENTICATION_METHOD = 'username_email' +ACCOUNT_AUTHENTICATION_METHOD = "username_email" ACCOUNT_EMAIL_REQUIRED = True -ACCOUNT_EMAIL_VERIFICATION = 'mandatory' +ACCOUNT_EMAIL_VERIFICATION = "mandatory" # Custom user app defaults # Select the correct user model -AUTH_USER_MODEL = 'users.User' -LOGIN_REDIRECT_URL = 'users:redirect' -LOGIN_URL = 'account_login' +AUTH_USER_MODEL = "users.User" +LOGIN_REDIRECT_URL = "users:redirect" +LOGIN_URL = "account_login" # SLUGLIFIER -AUTOSLUG_SLUGIFY_FUNCTION = 'slugify.slugify' +AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify" CACHE_DEFAULT = "redis://127.0.0.1:6379/0" -CACHES = { - "default": env.cache_url('CACHE_URL', default=CACHE_DEFAULT) -} +CACHES = {"default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT)} CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache" -from urllib.parse import urlparse -cache_url = urlparse(CACHES['default']['LOCATION']) + +cache_url = urlparse(CACHES["default"]["LOCATION"]) CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", - "CONFIG": { - "hosts": [(cache_url.hostname, cache_url.port)], - }, - }, + "CONFIG": {"hosts": [(cache_url.hostname, cache_url.port)]}, + } } CACHES["default"]["OPTIONS"] = { "CLIENT_CLASS": "django_redis.client.DefaultClient", "IGNORE_EXCEPTIONS": True, # mimics memcache behavior. - # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior + # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior } -########## CELERY -INSTALLED_APPS += ('funkwhale_api.taskapp.celery.CeleryConfig',) +# CELERY +INSTALLED_APPS += ("funkwhale_api.taskapp.celery.CeleryConfig",) CELERY_BROKER_URL = env( - "CELERY_BROKER_URL", default=env('CACHE_URL', default=CACHE_DEFAULT)) -########## END CELERY + "CELERY_BROKER_URL", default=env("CACHE_URL", default=CACHE_DEFAULT) +) +# END CELERY # Location of root django.contrib.admin URL, use {% url 'admin:index' %} # Your common stuff: Below this line define 3rd party library settings CELERY_TASK_DEFAULT_RATE_LIMIT = 1 CELERY_TASK_TIME_LIMIT = 300 CELERYBEAT_SCHEDULE = { - 'federation.clean_music_cache': { - 'task': 'funkwhale_api.federation.tasks.clean_music_cache', - 'schedule': crontab(hour='*/2'), - 'options': { - 'expires': 60 * 2, - }, + "federation.clean_music_cache": { + "task": "funkwhale_api.federation.tasks.clean_music_cache", + "schedule": crontab(hour="*/2"), + "options": {"expires": 60 * 2}, } } -import datetime JWT_AUTH = { - 'JWT_ALLOW_REFRESH': True, - 'JWT_EXPIRATION_DELTA': datetime.timedelta(days=7), - 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=30), - 'JWT_AUTH_HEADER_PREFIX': 'JWT', - 'JWT_GET_USER_SECRET_KEY': lambda user: user.secret_key + "JWT_ALLOW_REFRESH": True, + "JWT_EXPIRATION_DELTA": datetime.timedelta(days=7), + "JWT_REFRESH_EXPIRATION_DELTA": datetime.timedelta(days=30), + "JWT_AUTH_HEADER_PREFIX": "JWT", + "JWT_GET_USER_SECRET_KEY": lambda user: user.secret_key, } OLD_PASSWORD_FIELD_ENABLED = True -ACCOUNT_ADAPTER = 'funkwhale_api.users.adapters.FunkwhaleAccountAdapter' +ACCOUNT_ADAPTER = "funkwhale_api.users.adapters.FunkwhaleAccountAdapter" CORS_ORIGIN_ALLOW_ALL = True # CORS_ORIGIN_WHITELIST = ( # 'localhost', @@ -389,41 +367,37 @@ CORS_ORIGIN_ALLOW_ALL = True CORS_ALLOW_CREDENTIALS = True REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.IsAuthenticated', + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_PAGINATION_CLASS": "funkwhale_api.common.pagination.FunkwhalePagination", + "PAGE_SIZE": 25, + "DEFAULT_PARSER_CLASSES": ( + "rest_framework.parsers.JSONParser", + "rest_framework.parsers.FormParser", + "rest_framework.parsers.MultiPartParser", + "funkwhale_api.federation.parsers.ActivityParser", ), - 'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination', - 'PAGE_SIZE': 25, - 'DEFAULT_PARSER_CLASSES': ( - 'rest_framework.parsers.JSONParser', - 'rest_framework.parsers.FormParser', - 'rest_framework.parsers.MultiPartParser', - 'funkwhale_api.federation.parsers.ActivityParser', + "DEFAULT_AUTHENTICATION_CLASSES": ( + "funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS", + "funkwhale_api.common.authentication.BearerTokenHeaderAuth", + "rest_framework_jwt.authentication.JSONWebTokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", ), - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS', - 'funkwhale_api.common.authentication.BearerTokenHeaderAuth', - 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', - 'rest_framework.authentication.SessionAuthentication', - 'rest_framework.authentication.BasicAuthentication', + "DEFAULT_FILTER_BACKENDS": ( + "rest_framework.filters.OrderingFilter", + "django_filters.rest_framework.DjangoFilterBackend", ), - 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework.filters.OrderingFilter', - 'django_filters.rest_framework.DjangoFilterBackend', - ), - 'DEFAULT_RENDERER_CLASSES': ( - 'rest_framework.renderers.JSONRenderer', - ) + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), } -BROWSABLE_API_ENABLED = env.bool('BROWSABLE_API_ENABLED', default=False) +BROWSABLE_API_ENABLED = env.bool("BROWSABLE_API_ENABLED", default=False) if BROWSABLE_API_ENABLED: - REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] += ( - 'rest_framework.renderers.BrowsableAPIRenderer', + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] += ( + "rest_framework.renderers.BrowsableAPIRenderer", ) REST_AUTH_SERIALIZERS = { - 'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa + "PASSWORD_RESET_SERIALIZER": "funkwhale_api.users.serializers.PasswordResetSerializer" # noqa } REST_SESSION_LOGIN = False REST_USE_JWT = True @@ -434,60 +408,55 @@ USE_X_FORWARDED_PORT = True # Wether we should use Apache, Nginx (or other) headers when serving audio files # Default to Nginx -REVERSE_PROXY_TYPE = env('REVERSE_PROXY_TYPE', default='nginx') -assert REVERSE_PROXY_TYPE in ['apache2', 'nginx'], 'Unsupported REVERSE_PROXY_TYPE' +REVERSE_PROXY_TYPE = env("REVERSE_PROXY_TYPE", default="nginx") +assert REVERSE_PROXY_TYPE in ["apache2", "nginx"], "Unsupported REVERSE_PROXY_TYPE" # Which path will be used to process the internal redirection # **DO NOT** put a slash at the end -PROTECT_FILES_PATH = env('PROTECT_FILES_PATH', default='/_protected') +PROTECT_FILES_PATH = env("PROTECT_FILES_PATH", default="/_protected") # use this setting to tweak for how long you want to cache # musicbrainz results. (value is in seconds) -MUSICBRAINZ_CACHE_DURATION = env.int( - 'MUSICBRAINZ_CACHE_DURATION', - default=300 -) -CACHEOPS_REDIS = env('CACHE_URL', default=CACHE_DEFAULT) -CACHEOPS_ENABLED = env.bool('CACHEOPS_ENABLED', default=True) +MUSICBRAINZ_CACHE_DURATION = env.int("MUSICBRAINZ_CACHE_DURATION", default=300) +CACHEOPS_REDIS = env("CACHE_URL", default=CACHE_DEFAULT) +CACHEOPS_ENABLED = env.bool("CACHEOPS_ENABLED", default=True) CACHEOPS = { - 'music.artist': {'ops': 'all', 'timeout': 60 * 60}, - 'music.album': {'ops': 'all', 'timeout': 60 * 60}, - 'music.track': {'ops': 'all', 'timeout': 60 * 60}, - 'music.trackfile': {'ops': 'all', 'timeout': 60 * 60}, - 'taggit.tag': {'ops': 'all', 'timeout': 60 * 60}, + "music.artist": {"ops": "all", "timeout": 60 * 60}, + "music.album": {"ops": "all", "timeout": 60 * 60}, + "music.track": {"ops": "all", "timeout": 60 * 60}, + "music.trackfile": {"ops": "all", "timeout": 60 * 60}, + "taggit.tag": {"ops": "all", "timeout": 60 * 60}, } # Custom Admin URL, use {% url 'admin:index' %} -ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/') +ADMIN_URL = env("DJANGO_ADMIN_URL", default="^api/admin/") CSRF_USE_SESSIONS = True # Playlist settings # XXX: deprecated, see #186 -PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250) +PLAYLISTS_MAX_TRACKS = env.int("PLAYLISTS_MAX_TRACKS", default=250) ACCOUNT_USERNAME_BLACKLIST = [ - 'funkwhale', - 'library', - 'test', - 'status', - 'root', - 'admin', - 'owner', - 'superuser', - 'staff', - 'service', -] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[]) + "funkwhale", + "library", + "test", + "status", + "root", + "admin", + "owner", + "superuser", + "staff", + "service", +] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[]) -EXTERNAL_REQUESTS_VERIFY_SSL = env.bool( - 'EXTERNAL_REQUESTS_VERIFY_SSL', - default=True -) +EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True) # XXX: deprecated, see #186 API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True) -MUSIC_DIRECTORY_PATH = env('MUSIC_DIRECTORY_PATH', default=None) +MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None) # on Docker setup, the music directory may not match the host path, # and we need to know it for it to serve stuff properly MUSIC_DIRECTORY_SERVE_PATH = env( - 'MUSIC_DIRECTORY_SERVE_PATH', default=MUSIC_DIRECTORY_PATH) + "MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH +) diff --git a/api/config/settings/local.py b/api/config/settings/local.py index df14945cc..9f0119cee 100644 --- a/api/config/settings/local.py +++ b/api/config/settings/local.py @@ -1,79 +1,72 @@ # -*- coding: utf-8 -*- -''' +""" Local settings - Run in Debug mode - Use console backend for emails - Add Django Debug Toolbar - Add django-extensions as app -''' +""" from .common import * # noqa + # DEBUG # ------------------------------------------------------------------------------ -DEBUG = env.bool('DJANGO_DEBUG', default=True) -TEMPLATES[0]['OPTIONS']['debug'] = DEBUG +DEBUG = env.bool("DJANGO_DEBUG", default=True) +TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG # SECRET CONFIGURATION # ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # Note: This key only used for development and testing. -SECRET_KEY = env("DJANGO_SECRET_KEY", default='mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0sw@r5sua*1zp4fmxc') +SECRET_KEY = env( + "DJANGO_SECRET_KEY", default="mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0sw@r5sua*1zp4fmxc" +) # Mail settings # ------------------------------------------------------------------------------ -EMAIL_HOST = 'localhost' +EMAIL_HOST = "localhost" EMAIL_PORT = 1025 # django-debug-toolbar # ------------------------------------------------------------------------------ -MIDDLEWARE += ('debug_toolbar.middleware.DebugToolbarMiddleware',) +MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # INTERNAL_IPS = ('127.0.0.1', '10.0.2.2',) DEBUG_TOOLBAR_CONFIG = { - 'DISABLE_PANELS': [ - 'debug_toolbar.panels.redirects.RedirectsPanel', - ], - 'SHOW_TEMPLATE_CONTEXT': True, - 'SHOW_TOOLBAR_CALLBACK': lambda request: True, + "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], + "SHOW_TEMPLATE_CONTEXT": True, + "SHOW_TOOLBAR_CALLBACK": lambda request: True, } # django-extensions # ------------------------------------------------------------------------------ # INSTALLED_APPS += ('django_extensions', ) -INSTALLED_APPS += ('debug_toolbar', ) +INSTALLED_APPS += ("debug_toolbar",) # TESTING # ------------------------------------------------------------------------------ -TEST_RUNNER = 'django.test.runner.DiscoverRunner' +TEST_RUNNER = "django.test.runner.DiscoverRunner" -########## CELERY +# CELERY # In development, all tasks will be executed locally by blocking until the task returns CELERY_TASK_ALWAYS_EAGER = False -########## END CELERY +# END CELERY # Your local stuff: Below this line define 3rd party library settings LOGGING = { - 'version': 1, - 'handlers': { - 'console':{ - 'level':'DEBUG', - 'class':'logging.StreamHandler', - }, - }, - 'loggers': { - 'django.request': { - 'handlers':['console'], - 'propagate': True, - 'level':'DEBUG', - }, - '': { - 'level': 'DEBUG', - 'handlers': ['console'], + "version": 1, + "handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}}, + "loggers": { + "django.request": { + "handlers": ["console"], + "propagate": True, + "level": "DEBUG", }, + "": {"level": "DEBUG", "handlers": ["console"]}, }, } CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS] diff --git a/api/config/settings/production.py b/api/config/settings/production.py index 39be40dc3..72b08aa3c 100644 --- a/api/config/settings/production.py +++ b/api/config/settings/production.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -''' +""" Production Configurations - Use djangosecure @@ -8,12 +8,9 @@ Production Configurations - Use Redis on Heroku -''' +""" from __future__ import absolute_import, unicode_literals -from django.utils import six - - from .common import * # noqa # SECRET CONFIGURATION @@ -58,19 +55,24 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS # ------------------------------------------------------------------------------ # Uploaded Media Files # ------------------------ -DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' +DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage" # Static Assets # ------------------------ -STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage' +STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" # TEMPLATE CONFIGURATION # ------------------------------------------------------------------------------ # See: # https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.loaders.cached.Loader -TEMPLATES[0]['OPTIONS']['loaders'] = [ - ('django.template.loaders.cached.Loader', [ - 'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', ]), +TEMPLATES[0]["OPTIONS"]["loaders"] = [ + ( + "django.template.loaders.cached.Loader", + [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + ) ] # CACHING @@ -78,7 +80,6 @@ TEMPLATES[0]['OPTIONS']['loaders'] = [ # Heroku URL does not pass the DB number, so we parse it in - # LOGGING CONFIGURATION # ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/dev/ref/settings/#logging @@ -88,43 +89,39 @@ TEMPLATES[0]['OPTIONS']['loaders'] = [ # See http://docs.djangoproject.com/en/dev/topics/logging for # more details on how to customize your logging configuration. LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'filters': { - 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse' + "version": 1, + "disable_existing_loggers": False, + "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s " + "%(process)d %(thread)d %(message)s" } }, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s ' - '%(process)d %(thread)d %(message)s' + "handlers": { + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", + }, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", }, }, - 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': ['require_debug_false'], - 'class': 'django.utils.log.AdminEmailHandler' + "loggers": { + "django.request": { + "handlers": ["mail_admins"], + "level": "ERROR", + "propagate": True, }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'verbose', + "django.security.DisallowedHost": { + "level": "ERROR", + "handlers": ["console", "mail_admins"], + "propagate": True, }, }, - 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True - }, - 'django.security.DisallowedHost': { - 'level': 'ERROR', - 'handlers': ['console', 'mail_admins'], - 'propagate': True - } - } } diff --git a/api/config/urls.py b/api/config/urls.py index 90598ea84..5ffcf211b 100644 --- a/api/config/urls.py +++ b/api/config/urls.py @@ -5,38 +5,35 @@ from django.conf import settings from django.conf.urls import include, url from django.conf.urls.static import static from django.contrib import admin -from django.views.generic import TemplateView from django.views import defaults as default_views urlpatterns = [ # Django Admin, use {% url 'admin:index' %} url(settings.ADMIN_URL, admin.site.urls), - - url(r'^api/', include(("config.api_urls", 'api'), namespace="api")), - url(r'^', include( - ('funkwhale_api.federation.urls', 'federation'), - namespace="federation")), - url(r'^api/v1/auth/', include('rest_auth.urls')), - url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')), - url(r'^accounts/', include('allauth.urls')), - + url(r"^api/", include(("config.api_urls", "api"), namespace="api")), + url( + r"^", + include( + ("funkwhale_api.federation.urls", "federation"), namespace="federation" + ), + ), + url(r"^api/v1/auth/", include("rest_auth.urls")), + url(r"^api/v1/auth/registration/", include("funkwhale_api.users.rest_auth_urls")), + url(r"^accounts/", include("allauth.urls")), # Your stuff: custom urls includes go here - - ] if settings.DEBUG: # This allows the error pages to be debugged during development, just visit # these url in browser to see how these error pages look like. urlpatterns += [ - url(r'^400/$', default_views.bad_request), - url(r'^403/$', default_views.permission_denied), - url(r'^404/$', default_views.page_not_found), - url(r'^500/$', default_views.server_error), + url(r"^400/$", default_views.bad_request), + url(r"^403/$", default_views.permission_denied), + url(r"^404/$", default_views.page_not_found), + url(r"^500/$", default_views.server_error), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - if 'debug_toolbar' in settings.INSTALLED_APPS: + if "debug_toolbar" in settings.INSTALLED_APPS: import debug_toolbar - urlpatterns += [ - url(r'^__debug__/', include(debug_toolbar.urls)), - ] + + urlpatterns += [url(r"^__debug__/", include(debug_toolbar.urls))] diff --git a/api/config/wsgi.py b/api/config/wsgi.py index a53b580d7..8e843eb4d 100644 --- a/api/config/wsgi.py +++ b/api/config/wsgi.py @@ -15,11 +15,9 @@ framework. """ import os - from django.core.wsgi import get_wsgi_application from whitenoise.django import DjangoWhiteNoise - # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks # if running multiple sites in the same mod_wsgi process. To fix this, use # mod_wsgi daemon mode with each site in its own daemon process, or use diff --git a/api/demo/demo-user.py b/api/demo/demo-user.py index 4f8648fb3..94757d2fa 100644 --- a/api/demo/demo-user.py +++ b/api/demo/demo-user.py @@ -1,7 +1,7 @@ from funkwhale_api.users.models import User -u = User.objects.create(email='demo@demo.com', username='demo', is_staff=True) -u.set_password('demo') -u.subsonic_api_token = 'demo' +u = User.objects.create(email="demo@demo.com", username="demo", is_staff=True) +u.set_password("demo") +u.subsonic_api_token = "demo" u.save() diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py index 8b5b81ad4..44b80d2dc 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -1,3 +1,8 @@ # -*- coding: utf-8 -*- -__version__ = '0.14.1' -__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) +__version__ = "0.14.2" +__version_info__ = tuple( + [ + int(num) if num.isdigit() else num + for num in __version__.replace("-", ".", 1).split(".") + ] +) diff --git a/api/funkwhale_api/activity/apps.py b/api/funkwhale_api/activity/apps.py index 0c66cbf50..b70f65c57 100644 --- a/api/funkwhale_api/activity/apps.py +++ b/api/funkwhale_api/activity/apps.py @@ -2,8 +2,9 @@ from django.apps import AppConfig, apps from . import record + class ActivityConfig(AppConfig): - name = 'funkwhale_api.activity' + name = "funkwhale_api.activity" def ready(self): super(ActivityConfig, self).ready() diff --git a/api/funkwhale_api/activity/record.py b/api/funkwhale_api/activity/record.py index fa55c0e85..3e34b1027 100644 --- a/api/funkwhale_api/activity/record.py +++ b/api/funkwhale_api/activity/record.py @@ -2,37 +2,36 @@ import persisting_theory class ActivityRegistry(persisting_theory.Registry): - look_into = 'activities' + look_into = "activities" def _register_for_model(self, model, attr, value): key = model._meta.label - d = self.setdefault(key, {'consumers': []}) + d = self.setdefault(key, {"consumers": []}) d[attr] = value def register_serializer(self, serializer_class): model = serializer_class.Meta.model - self._register_for_model(model, 'serializer', serializer_class) + self._register_for_model(model, "serializer", serializer_class) return serializer_class def register_consumer(self, label): def decorator(func): - consumers = self[label]['consumers'] + consumers = self[label]["consumers"] if func not in consumers: consumers.append(func) return func + return decorator registry = ActivityRegistry() - - def send(obj): conf = registry[obj.__class__._meta.label] - consumers = conf['consumers'] + consumers = conf["consumers"] if not consumers: return - serializer = conf['serializer'](obj) + serializer = conf["serializer"](obj) for consumer in consumers: consumer(data=serializer.data, obj=obj) diff --git a/api/funkwhale_api/activity/serializers.py b/api/funkwhale_api/activity/serializers.py index fd9b185cf..6df3a5870 100644 --- a/api/funkwhale_api/activity/serializers.py +++ b/api/funkwhale_api/activity/serializers.py @@ -4,8 +4,8 @@ from funkwhale_api.activity import record class ModelSerializer(serializers.ModelSerializer): - id = serializers.CharField(source='get_activity_url') - local_id = serializers.IntegerField(source='id') + id = serializers.CharField(source="get_activity_url") + local_id = serializers.IntegerField(source="id") # url = serializers.SerializerMethodField() def get_url(self, obj): @@ -17,8 +17,7 @@ class AutoSerializer(serializers.Serializer): A serializer that will automatically use registered activity serializers to serialize an henerogeneous list of objects (favorites, listenings, etc.) """ + def to_representation(self, instance): - serializer = record.registry[instance._meta.label]['serializer']( - instance - ) + serializer = record.registry[instance._meta.label]["serializer"](instance) return serializer.data diff --git a/api/funkwhale_api/activity/utils.py b/api/funkwhale_api/activity/utils.py index 46336930e..236d23d88 100644 --- a/api/funkwhale_api/activity/utils.py +++ b/api/funkwhale_api/activity/utils.py @@ -6,31 +6,25 @@ from funkwhale_api.history.models import Listening def combined_recent(limit, **kwargs): - datetime_field = kwargs.pop('datetime_field', 'creation_date') - source_querysets = { - qs.model._meta.label: qs for qs in kwargs.pop('querysets') - } + datetime_field = kwargs.pop("datetime_field", "creation_date") + source_querysets = {qs.model._meta.label: qs for qs in kwargs.pop("querysets")} querysets = { k: qs.annotate( - __type=models.Value( - qs.model._meta.label, output_field=models.CharField() - ) - ).values('pk', datetime_field, '__type') + __type=models.Value(qs.model._meta.label, output_field=models.CharField()) + ).values("pk", datetime_field, "__type") for k, qs in source_querysets.items() } _qs_list = list(querysets.values()) union_qs = _qs_list[0].union(*_qs_list[1:]) records = [] - for row in union_qs.order_by('-{}'.format(datetime_field))[:limit]: - records.append({ - 'type': row['__type'], - 'when': row[datetime_field], - 'pk': row['pk'] - }) + for row in union_qs.order_by("-{}".format(datetime_field))[:limit]: + records.append( + {"type": row["__type"], "when": row[datetime_field], "pk": row["pk"]} + ) # Now we bulk-load each object type in turn to_load = {} for record in records: - to_load.setdefault(record['type'], []).append(record['pk']) + to_load.setdefault(record["type"], []).append(record["pk"]) fetched = {} for key, pks in to_load.items(): @@ -39,26 +33,19 @@ def combined_recent(limit, **kwargs): # Annotate 'records' with loaded objects for record in records: - record['object'] = fetched[(record['type'], record['pk'])] + record["object"] = fetched[(record["type"], record["pk"])] return records def get_activity(user, limit=20): - query = fields.privacy_level_query( - user, lookup_field='user__privacy_level') + query = fields.privacy_level_query(user, lookup_field="user__privacy_level") querysets = [ Listening.objects.filter(query).select_related( - 'track', - 'user', - 'track__artist', - 'track__album__artist', + "track", "user", "track__artist", "track__album__artist" ), TrackFavorite.objects.filter(query).select_related( - 'track', - 'user', - 'track__artist', - 'track__album__artist', + "track", "user", "track__artist", "track__album__artist" ), ] records = combined_recent(limit=limit, querysets=querysets) - return [r['object'] for r in records] + return [r["object"] for r in records] diff --git a/api/funkwhale_api/activity/views.py b/api/funkwhale_api/activity/views.py index e66de1ccf..701dd04b8 100644 --- a/api/funkwhale_api/activity/views.py +++ b/api/funkwhale_api/activity/views.py @@ -4,8 +4,7 @@ from rest_framework.response import Response from funkwhale_api.common.permissions import ConditionalAuthentication from funkwhale_api.favorites.models import TrackFavorite -from . import serializers -from . import utils +from . import serializers, utils class ActivityViewSet(viewsets.GenericViewSet): @@ -17,4 +16,4 @@ class ActivityViewSet(viewsets.GenericViewSet): def list(self, request, *args, **kwargs): activity = utils.get_activity(user=request.user) serializer = self.serializer_class(activity, many=True) - return Response({'results': serializer.data}, status=200) + return Response({"results": serializer.data}, status=200) diff --git a/api/funkwhale_api/common/auth.py b/api/funkwhale_api/common/auth.py index faf13571d..7717c836b 100644 --- a/api/funkwhale_api/common/auth.py +++ b/api/funkwhale_api/common/auth.py @@ -1,12 +1,7 @@ from urllib.parse import parse_qs -import jwt - from django.contrib.auth.models import AnonymousUser -from django.utils.encoding import smart_text - from rest_framework import exceptions -from rest_framework_jwt.settings import api_settings from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication from funkwhale_api.users.models import User @@ -16,20 +11,19 @@ class TokenHeaderAuth(BaseJSONWebTokenAuthentication): def get_jwt_value(self, request): try: - qs = request.get('query_string', b'').decode('utf-8') + qs = request.get("query_string", b"").decode("utf-8") parsed = parse_qs(qs) - token = parsed['token'][0] + token = parsed["token"][0] except KeyError: - raise exceptions.AuthenticationFailed('No token') + raise exceptions.AuthenticationFailed("No token") if not token: - raise exceptions.AuthenticationFailed('Empty token') + raise exceptions.AuthenticationFailed("Empty token") return token class TokenAuthMiddleware: - def __init__(self, inner): # Store the ASGI application we were passed self.inner = inner @@ -41,5 +35,5 @@ class TokenAuthMiddleware: except (User.DoesNotExist, exceptions.AuthenticationFailed): user = AnonymousUser() - scope['user'] = user + scope["user"] = user return self.inner(scope) diff --git a/api/funkwhale_api/common/authentication.py b/api/funkwhale_api/common/authentication.py index c7566eac8..10bf36613 100644 --- a/api/funkwhale_api/common/authentication.py +++ b/api/funkwhale_api/common/authentication.py @@ -1,39 +1,38 @@ from django.utils.encoding import smart_text from django.utils.translation import ugettext as _ - from rest_framework import exceptions from rest_framework_jwt import authentication from rest_framework_jwt.settings import api_settings -class JSONWebTokenAuthenticationQS( - authentication.BaseJSONWebTokenAuthentication): +class JSONWebTokenAuthenticationQS(authentication.BaseJSONWebTokenAuthentication): - www_authenticate_realm = 'api' + www_authenticate_realm = "api" def get_jwt_value(self, request): - token = request.query_params.get('jwt') - if 'jwt' in request.query_params and not token: - msg = _('Invalid Authorization header. No credentials provided.') + token = request.query_params.get("jwt") + if "jwt" in request.query_params and not token: + msg = _("Invalid Authorization header. No credentials provided.") raise exceptions.AuthenticationFailed(msg) return token def authenticate_header(self, request): return '{0} realm="{1}"'.format( - api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm) + api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm + ) -class BearerTokenHeaderAuth( - authentication.BaseJSONWebTokenAuthentication): +class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication): """ For backward compatibility purpose, we used Authorization: JWT but Authorization: Bearer is probably better. """ - www_authenticate_realm = 'api' + + www_authenticate_realm = "api" def get_jwt_value(self, request): auth = authentication.get_authorization_header(request).split() - auth_header_prefix = 'bearer' + auth_header_prefix = "bearer" if not auth: if api_settings.JWT_AUTH_COOKIE: @@ -44,14 +43,16 @@ class BearerTokenHeaderAuth( return None if len(auth) == 1: - msg = _('Invalid Authorization header. No credentials provided.') + msg = _("Invalid Authorization header. No credentials provided.") raise exceptions.AuthenticationFailed(msg) elif len(auth) > 2: - msg = _('Invalid Authorization header. Credentials string ' - 'should not contain spaces.') + msg = _( + "Invalid Authorization header. Credentials string " + "should not contain spaces." + ) raise exceptions.AuthenticationFailed(msg) return auth[1] def authenticate_header(self, request): - return '{0} realm="{1}"'.format('Bearer', self.www_authenticate_realm) + return '{0} realm="{1}"'.format("Bearer", self.www_authenticate_realm) diff --git a/api/funkwhale_api/common/consumers.py b/api/funkwhale_api/common/consumers.py index 300ce5e26..47a666f05 100644 --- a/api/funkwhale_api/common/consumers.py +++ b/api/funkwhale_api/common/consumers.py @@ -1,11 +1,12 @@ from channels.generic.websocket import JsonWebsocketConsumer + from funkwhale_api.common import channels class JsonAuthConsumer(JsonWebsocketConsumer): def connect(self): try: - assert self.scope['user'].pk is not None + assert self.scope["user"].pk is not None except (AssertionError, AttributeError, KeyError): return self.close() diff --git a/api/funkwhale_api/common/dynamic_preferences_registry.py b/api/funkwhale_api/common/dynamic_preferences_registry.py index 15b182671..d6dfed783 100644 --- a/api/funkwhale_api/common/dynamic_preferences_registry.py +++ b/api/funkwhale_api/common/dynamic_preferences_registry.py @@ -3,18 +3,19 @@ from dynamic_preferences.registries import global_preferences_registry from funkwhale_api.common import preferences -common = types.Section('common') +common = types.Section("common") @global_preferences_registry.register class APIAutenticationRequired( - preferences.DefaultFromSettingMixin, types.BooleanPreference): + preferences.DefaultFromSettingMixin, types.BooleanPreference +): section = common - name = 'api_authentication_required' - verbose_name = 'API Requires authentication' - setting = 'API_AUTHENTICATION_REQUIRED' + name = "api_authentication_required" + verbose_name = "API Requires authentication" + setting = "API_AUTHENTICATION_REQUIRED" help_text = ( - 'If disabled, anonymous users will be able to query the API' - 'and access music data (as well as other data exposed in the API ' - 'without specific permissions).' + "If disabled, anonymous users will be able to query the API" + "and access music data (as well as other data exposed in the API " + "without specific permissions)." ) diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index 98e971662..190576efa 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -1,39 +1,34 @@ import django_filters - from django.db import models from funkwhale_api.music import utils - PRIVACY_LEVEL_CHOICES = [ - ('me', 'Only me'), - ('followers', 'Me and my followers'), - ('instance', 'Everyone on my instance, and my followers'), - ('everyone', 'Everyone, including people on other instances'), + ("me", "Only me"), + ("followers", "Me and my followers"), + ("instance", "Everyone on my instance, and my followers"), + ("everyone", "Everyone, including people on other instances"), ] def get_privacy_field(): return models.CharField( - max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance') + max_length=30, choices=PRIVACY_LEVEL_CHOICES, default="instance" + ) -def privacy_level_query(user, lookup_field='privacy_level'): +def privacy_level_query(user, lookup_field="privacy_level"): if user.is_anonymous: - return models.Q(**{ - lookup_field: 'everyone', - }) + return models.Q(**{lookup_field: "everyone"}) - return models.Q(**{ - '{}__in'.format(lookup_field): [ - 'followers', 'instance', 'everyone' - ] - }) + return models.Q( + **{"{}__in".format(lookup_field): ["followers", "instance", "everyone"]} + ) class SearchFilter(django_filters.CharFilter): def __init__(self, *args, **kwargs): - self.search_fields = kwargs.pop('search_fields') + self.search_fields = kwargs.pop("search_fields") super().__init__(*args, **kwargs) def filter(self, qs, value): diff --git a/api/funkwhale_api/common/management/commands/script.py b/api/funkwhale_api/common/management/commands/script.py index 9d26a5836..b46a4327b 100644 --- a/api/funkwhale_api/common/management/commands/script.py +++ b/api/funkwhale_api/common/management/commands/script.py @@ -4,17 +4,20 @@ from funkwhale_api.common import scripts class Command(BaseCommand): - help = 'Run a specific script from funkwhale_api/common/scripts/' + help = "Run a specific script from funkwhale_api/common/scripts/" def add_arguments(self, parser): - parser.add_argument('script_name', nargs='?', type=str) + parser.add_argument("script_name", nargs="?", type=str) parser.add_argument( - '--noinput', '--no-input', action='store_false', dest='interactive', + "--noinput", + "--no-input", + action="store_false", + dest="interactive", help="Do NOT prompt the user for input of any kind.", ) def handle(self, *args, **options): - name = options['script_name'] + name = options["script_name"] if not name: self.show_help() @@ -23,44 +26,43 @@ class Command(BaseCommand): script = available_scripts[name] except KeyError: raise CommandError( - '{} is not a valid script. Run python manage.py script for a ' - 'list of available scripts'.format(name)) + "{} is not a valid script. Run python manage.py script for a " + "list of available scripts".format(name) + ) - self.stdout.write('') - if options['interactive']: + self.stdout.write("") + if options["interactive"]: message = ( - 'Are you sure you want to execute the script {}?\n\n' + "Are you sure you want to execute the script {}?\n\n" "Type 'yes' to continue, or 'no' to cancel: " ).format(name) - if input(''.join(message)) != 'yes': + if input("".join(message)) != "yes": raise CommandError("Script cancelled.") - script['entrypoint'](self, **options) + script["entrypoint"](self, **options) def show_help(self): - indentation = 4 - self.stdout.write('') - self.stdout.write('Available scripts:') - self.stdout.write('Launch with: python manage.py ') + self.stdout.write("") + self.stdout.write("Available scripts:") + self.stdout.write("Launch with: python manage.py ") available_scripts = self.get_scripts() for name, script in sorted(available_scripts.items()): - self.stdout.write('') + self.stdout.write("") self.stdout.write(self.style.SUCCESS(name)) - self.stdout.write('') - for line in script['help'].splitlines(): - self.stdout.write('     {}'.format(line)) - self.stdout.write('') + self.stdout.write("") + for line in script["help"].splitlines(): + self.stdout.write("     {}".format(line)) + self.stdout.write("") def get_scripts(self): available_scripts = [ - k for k in sorted(scripts.__dict__.keys()) - if not k.startswith('__') + k for k in sorted(scripts.__dict__.keys()) if not k.startswith("__") ] data = {} for name in available_scripts: module = getattr(scripts, name) data[name] = { - 'name': name, - 'help': module.__doc__.strip(), - 'entrypoint': module.main + "name": name, + "help": module.__doc__.strip(), + "entrypoint": module.main, } return data diff --git a/api/funkwhale_api/common/migrations/0001_initial.py b/api/funkwhale_api/common/migrations/0001_initial.py index e95cc11e9..a362855b8 100644 --- a/api/funkwhale_api/common/migrations/0001_initial.py +++ b/api/funkwhale_api/common/migrations/0001_initial.py @@ -7,6 +7,4 @@ class Migration(migrations.Migration): dependencies = [] - operations = [ - UnaccentExtension() - ] + operations = [UnaccentExtension()] diff --git a/api/funkwhale_api/common/pagination.py b/api/funkwhale_api/common/pagination.py index 20efcb741..e5068bce2 100644 --- a/api/funkwhale_api/common/pagination.py +++ b/api/funkwhale_api/common/pagination.py @@ -2,5 +2,5 @@ from rest_framework.pagination import PageNumberPagination class FunkwhalePagination(PageNumberPagination): - page_size_query_param = 'page_size' + page_size_query_param = "page_size" max_page_size = 50 diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py index e9e8b8819..8f391a70c 100644 --- a/api/funkwhale_api/common/permissions.py +++ b/api/funkwhale_api/common/permissions.py @@ -1,17 +1,14 @@ import operator -from django.conf import settings from django.http import Http404 - from rest_framework.permissions import BasePermission from funkwhale_api.common import preferences class ConditionalAuthentication(BasePermission): - def has_permission(self, request, view): - if preferences.get('common__api_authentication_required'): + if preferences.get("common__api_authentication_required"): return request.user and request.user.is_authenticated return True @@ -28,24 +25,25 @@ class OwnerPermission(BasePermission): owner_field = 'owner' owner_checks = ['read', 'write'] """ + perms_map = { - 'GET': 'read', - 'OPTIONS': 'read', - 'HEAD': 'read', - 'POST': 'write', - 'PUT': 'write', - 'PATCH': 'write', - 'DELETE': 'write', + "GET": "read", + "OPTIONS": "read", + "HEAD": "read", + "POST": "write", + "PUT": "write", + "PATCH": "write", + "DELETE": "write", } def has_object_permission(self, request, view, obj): method_check = self.perms_map[request.method] - owner_checks = getattr(view, 'owner_checks', ['read', 'write']) + owner_checks = getattr(view, "owner_checks", ["read", "write"]) if method_check not in owner_checks: # check not enabled return True - owner_field = getattr(view, 'owner_field', 'user') + owner_field = getattr(view, "owner_field", "user") owner = operator.attrgetter(owner_field)(obj) if owner != request.user: raise Http404 diff --git a/api/funkwhale_api/common/preferences.py b/api/funkwhale_api/common/preferences.py index a2d3f04b7..acda9a90c 100644 --- a/api/funkwhale_api/common/preferences.py +++ b/api/funkwhale_api/common/preferences.py @@ -1,8 +1,6 @@ -from django.conf import settings from django import forms - -from dynamic_preferences import serializers -from dynamic_preferences import types +from django.conf import settings +from dynamic_preferences import serializers, types from dynamic_preferences.registries import global_preferences_registry @@ -17,7 +15,7 @@ def get(pref): class StringListSerializer(serializers.BaseSerializer): - separator = ',' + separator = "," sort = True @classmethod @@ -27,8 +25,8 @@ class StringListSerializer(serializers.BaseSerializer): if type(value) not in [list, tuple]: raise cls.exception( - "Cannot serialize, value {} is not a list or a tuple".format( - value)) + "Cannot serialize, value {} is not a list or a tuple".format(value) + ) if cls.sort: value = sorted(value) @@ -38,7 +36,7 @@ class StringListSerializer(serializers.BaseSerializer): def to_python(cls, value, **kwargs): if not value: return [] - return value.split(',') + return value.split(",") class StringListPreference(types.BasePreferenceType): @@ -47,5 +45,5 @@ class StringListPreference(types.BasePreferenceType): def get_api_additional_data(self): d = super(StringListPreference, self).get_api_additional_data() - d['choices'] = self.get('choices') + d["choices"] = self.get("choices") return d diff --git a/api/funkwhale_api/common/scripts/__init__.py b/api/funkwhale_api/common/scripts/__init__.py index 4b2d52520..e69de29bb 100644 --- a/api/funkwhale_api/common/scripts/__init__.py +++ b/api/funkwhale_api/common/scripts/__init__.py @@ -1,2 +0,0 @@ -from . import django_permissions_to_user_permissions -from . import test diff --git a/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py b/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py index 1bc971f80..48144f8ea 100644 --- a/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py +++ b/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py @@ -2,28 +2,28 @@ Convert django permissions to user permissions in the database, following the work done in #152. """ +from django.contrib.auth.models import Permission from django.db.models import Q + from funkwhale_api.users import models -from django.contrib.auth.models import Permission - mapping = { - 'dynamic_preferences.change_globalpreferencemodel': 'settings', - 'music.add_importbatch': 'library', - 'federation.change_library': 'federation', + "dynamic_preferences.change_globalpreferencemodel": "settings", + "music.add_importbatch": "library", + "federation.change_library": "federation", } def main(command, **kwargs): for codename, user_permission in sorted(mapping.items()): - app_label, c = codename.split('.') - p = Permission.objects.get( - content_type__app_label=app_label, codename=c) + app_label, c = codename.split(".") + p = Permission.objects.get(content_type__app_label=app_label, codename=c) users = models.User.objects.filter( - Q(groups__permissions=p) | Q(user_permissions=p)).distinct() + Q(groups__permissions=p) | Q(user_permissions=p) + ).distinct() total = users.count() - command.stdout.write('Updating {} users with {} permission...'.format( - total, user_permission - )) - users.update(**{'permission_{}'.format(user_permission): True}) + command.stdout.write( + "Updating {} users with {} permission...".format(total, user_permission) + ) + users.update(**{"permission_{}".format(user_permission): True}) diff --git a/api/funkwhale_api/common/scripts/test.py b/api/funkwhale_api/common/scripts/test.py index ab401dca4..b3a27f402 100644 --- a/api/funkwhale_api/common/scripts/test.py +++ b/api/funkwhale_api/common/scripts/test.py @@ -5,4 +5,4 @@ You can launch it just to check how it works. def main(command, **kwargs): - command.stdout.write('Test script run successfully') + command.stdout.write("Test script run successfully") diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index a995cc360..029338ef9 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -17,67 +17,67 @@ class ActionSerializer(serializers.Serializer): dangerous_actions = [] def __init__(self, *args, **kwargs): - self.queryset = kwargs.pop('queryset') + self.queryset = kwargs.pop("queryset") if self.actions is None: raise ValueError( - 'You must declare a list of actions on ' - 'the serializer class') + "You must declare a list of actions on " "the serializer class" + ) for action in self.actions: - handler_name = 'handle_{}'.format(action) - assert hasattr(self, handler_name), ( - '{} miss a {} method'.format( - self.__class__.__name__, handler_name) + handler_name = "handle_{}".format(action) + assert hasattr(self, handler_name), "{} miss a {} method".format( + self.__class__.__name__, handler_name ) super().__init__(self, *args, **kwargs) def validate_action(self, value): if value not in self.actions: raise serializers.ValidationError( - '{} is not a valid action. Pick one of {}.'.format( - value, ', '.join(self.actions) + "{} is not a valid action. Pick one of {}.".format( + value, ", ".join(self.actions) ) ) return value def validate_objects(self, value): - qs = None - if value == 'all': - return self.queryset.all().order_by('id') + if value == "all": + return self.queryset.all().order_by("id") if type(value) in [list, tuple]: - return self.queryset.filter(pk__in=value).order_by('id') + return self.queryset.filter(pk__in=value).order_by("id") raise serializers.ValidationError( - '{} is not a valid value for objects. You must provide either a ' - 'list of identifiers or the string "all".'.format(value)) + "{} is not a valid value for objects. You must provide either a " + 'list of identifiers or the string "all".'.format(value) + ) def validate(self, data): - dangerous = data['action'] in self.dangerous_actions - if dangerous and self.initial_data['objects'] == 'all': + dangerous = data["action"] in self.dangerous_actions + if dangerous and self.initial_data["objects"] == "all": raise serializers.ValidationError( - 'This action is to dangerous to be applied to all objects') - if self.filterset_class and 'filters' in data: + "This action is to dangerous to be applied to all objects" + ) + if self.filterset_class and "filters" in data: qs_filterset = self.filterset_class( - data['filters'], queryset=data['objects']) + data["filters"], queryset=data["objects"] + ) try: assert qs_filterset.form.is_valid() except (AssertionError, TypeError): - raise serializers.ValidationError('Invalid filters') - data['objects'] = qs_filterset.qs + raise serializers.ValidationError("Invalid filters") + data["objects"] = qs_filterset.qs - data['count'] = data['objects'].count() - if data['count'] < 1: - raise serializers.ValidationError( - 'No object matching your request') + data["count"] = data["objects"].count() + if data["count"] < 1: + raise serializers.ValidationError("No object matching your request") return data def save(self): - handler_name = 'handle_{}'.format(self.validated_data['action']) + handler_name = "handle_{}".format(self.validated_data["action"]) handler = getattr(self, handler_name) - result = handler(self.validated_data['objects']) + result = handler(self.validated_data["objects"]) payload = { - 'updated': self.validated_data['count'], - 'action': self.validated_data['action'], - 'result': result, + "updated": self.validated_data["count"], + "action": self.validated_data["action"], + "result": result, } return payload diff --git a/api/funkwhale_api/common/session.py b/api/funkwhale_api/common/session.py index 7f5584bd1..4d5d0bb60 100644 --- a/api/funkwhale_api/common/session.py +++ b/api/funkwhale_api/common/session.py @@ -1,18 +1,16 @@ import requests - from django.conf import settings import funkwhale_api def get_user_agent(): - return 'python-requests (funkwhale/{}; +{})'.format( - funkwhale_api.__version__, - settings.FUNKWHALE_URL + return "python-requests (funkwhale/{}; +{})".format( + funkwhale_api.__version__, settings.FUNKWHALE_URL ) def get_session(): s = requests.Session() - s.headers['User-Agent'] = get_user_agent() + s.headers["User-Agent"] = get_user_agent() return s diff --git a/api/funkwhale_api/common/storage.py b/api/funkwhale_api/common/storage.py index 658ce795a..c5651693f 100644 --- a/api/funkwhale_api/common/storage.py +++ b/api/funkwhale_api/common/storage.py @@ -7,6 +7,7 @@ class ASCIIFileSystemStorage(FileSystemStorage): """ Convert unicode characters in name to ASCII characters. """ + def get_valid_name(self, name): - name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore') + name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore") return super().get_valid_name(name) diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 2d7641bf5..221d2336b 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -1,6 +1,6 @@ -from urllib.parse import urlencode, parse_qs, urlsplit, urlunsplit import os import shutil +from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from django.db import transaction @@ -9,13 +9,13 @@ def rename_file(instance, field_name, new_name, allow_missing_file=False): field = getattr(instance, field_name) current_name, extension = os.path.splitext(field.name) - new_name_with_extension = '{}{}'.format(new_name, extension) + new_name_with_extension = "{}{}".format(new_name, extension) try: shutil.move(field.path, new_name_with_extension) except FileNotFoundError: if not allow_missing_file: raise - print('Skipped missing file', field.path) + print("Skipped missing file", field.path) initial_path = os.path.dirname(field.name) field.name = os.path.join(initial_path, new_name_with_extension) instance.save() @@ -23,9 +23,7 @@ def rename_file(instance, field_name, new_name, allow_missing_file=False): def on_commit(f, *args, **kwargs): - return transaction.on_commit( - lambda: f(*args, **kwargs) - ) + return transaction.on_commit(lambda: f(*args, **kwargs)) def set_query_parameter(url, **kwargs): diff --git a/api/funkwhale_api/contrib/sites/migrations/0001_initial.py b/api/funkwhale_api/contrib/sites/migrations/0001_initial.py index cf95cec65..8b7ec088c 100644 --- a/api/funkwhale_api/contrib/sites/migrations/0001_initial.py +++ b/api/funkwhale_api/contrib/sites/migrations/0001_initial.py @@ -7,25 +7,39 @@ import django.contrib.sites.models class Migration(migrations.Migration): - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Site', + name="Site", fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), - ('domain', models.CharField(verbose_name='domain name', max_length=100, validators=[django.contrib.sites.models._simple_domain_name_validator])), - ('name', models.CharField(verbose_name='display name', max_length=50)), + ( + "id", + models.AutoField( + verbose_name="ID", + primary_key=True, + serialize=False, + auto_created=True, + ), + ), + ( + "domain", + models.CharField( + verbose_name="domain name", + max_length=100, + validators=[ + django.contrib.sites.models._simple_domain_name_validator + ], + ), + ), + ("name", models.CharField(verbose_name="display name", max_length=50)), ], options={ - 'verbose_name_plural': 'sites', - 'verbose_name': 'site', - 'db_table': 'django_site', - 'ordering': ('domain',), + "verbose_name_plural": "sites", + "verbose_name": "site", + "db_table": "django_site", + "ordering": ("domain",), }, - managers=[ - ('objects', django.contrib.sites.models.SiteManager()), - ], - ), + managers=[("objects", django.contrib.sites.models.SiteManager())], + ) ] diff --git a/api/funkwhale_api/contrib/sites/migrations/0002_set_site_domain_and_name.py b/api/funkwhale_api/contrib/sites/migrations/0002_set_site_domain_and_name.py index e92c8c338..7b091708c 100644 --- a/api/funkwhale_api/contrib/sites/migrations/0002_set_site_domain_and_name.py +++ b/api/funkwhale_api/contrib/sites/migrations/0002_set_site_domain_and_name.py @@ -10,10 +10,7 @@ def update_site_forward(apps, schema_editor): Site = apps.get_model("sites", "Site") Site.objects.update_or_create( id=settings.SITE_ID, - defaults={ - "domain": "funkwhale.io", - "name": "funkwhale_api" - } + defaults={"domain": "funkwhale.io", "name": "funkwhale_api"}, ) @@ -21,20 +18,12 @@ def update_site_backward(apps, schema_editor): """Revert site domain and name to default.""" Site = apps.get_model("sites", "Site") Site.objects.update_or_create( - id=settings.SITE_ID, - defaults={ - "domain": "example.com", - "name": "example.com" - } + id=settings.SITE_ID, defaults={"domain": "example.com", "name": "example.com"} ) class Migration(migrations.Migration): - dependencies = [ - ('sites', '0001_initial'), - ] + dependencies = [("sites", "0001_initial")] - operations = [ - migrations.RunPython(update_site_forward, update_site_backward), - ] + operations = [migrations.RunPython(update_site_forward, update_site_backward)] diff --git a/api/funkwhale_api/contrib/sites/migrations/0003_auto_20171214_2205.py b/api/funkwhale_api/contrib/sites/migrations/0003_auto_20171214_2205.py index 14a9ec1a8..5a903b8d1 100644 --- a/api/funkwhale_api/contrib/sites/migrations/0003_auto_20171214_2205.py +++ b/api/funkwhale_api/contrib/sites/migrations/0003_auto_20171214_2205.py @@ -8,20 +8,21 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('sites', '0002_set_site_domain_and_name'), - ] + dependencies = [("sites", "0002_set_site_domain_and_name")] operations = [ migrations.AlterModelManagers( - name='site', - managers=[ - ('objects', django.contrib.sites.models.SiteManager()), - ], + name="site", + managers=[("objects", django.contrib.sites.models.SiteManager())], ), migrations.AlterField( - model_name='site', - name='domain', - field=models.CharField(max_length=100, unique=True, validators=[django.contrib.sites.models._simple_domain_name_validator], verbose_name='domain name'), + model_name="site", + name="domain", + field=models.CharField( + max_length=100, + unique=True, + validators=[django.contrib.sites.models._simple_domain_name_validator], + verbose_name="domain name", + ), ), ] diff --git a/api/funkwhale_api/downloader/__init__.py b/api/funkwhale_api/downloader/__init__.py index 29ec89954..eca15e121 100644 --- a/api/funkwhale_api/downloader/__init__.py +++ b/api/funkwhale_api/downloader/__init__.py @@ -1,2 +1,3 @@ - from .downloader import download + +__all__ = ["download"] diff --git a/api/funkwhale_api/downloader/downloader.py b/api/funkwhale_api/downloader/downloader.py index 7fc237b08..f2b7568cc 100644 --- a/api/funkwhale_api/downloader/downloader.py +++ b/api/funkwhale_api/downloader/downloader.py @@ -1,26 +1,19 @@ import os -import json -from urllib.parse import quote_plus + import youtube_dl from django.conf import settings -import glob def download( - url, - target_directory=settings.MEDIA_ROOT, - name="%(id)s.%(ext)s", - bitrate=192): + url, target_directory=settings.MEDIA_ROOT, name="%(id)s.%(ext)s", bitrate=192 +): target_path = os.path.join(target_directory, name) ydl_opts = { - 'quiet': True, - 'outtmpl': target_path, - 'postprocessors': [{ - 'key': 'FFmpegExtractAudio', - 'preferredcodec': 'vorbis', - }], + "quiet": True, + "outtmpl": target_path, + "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "vorbis"}], } _downloader = youtube_dl.YoutubeDL(ydl_opts) info = _downloader.extract_info(url) - info['audio_file_path'] = target_path % {'id': info['id'], 'ext': 'ogg'} + info["audio_file_path"] = target_path % {"id": info["id"], "ext": "ogg"} return info diff --git a/api/funkwhale_api/factories.py b/api/funkwhale_api/factories.py index 6fed66edb..602037a06 100644 --- a/api/funkwhale_api/factories.py +++ b/api/funkwhale_api/factories.py @@ -3,7 +3,7 @@ import persisting_theory class FactoriesRegistry(persisting_theory.Registry): - look_into = 'factories' + look_into = "factories" def prepare_name(self, data, name=None): return name or data._meta.model._meta.label diff --git a/api/funkwhale_api/favorites/activities.py b/api/funkwhale_api/favorites/activities.py index a2dbc4e2f..294194e06 100644 --- a/api/funkwhale_api/favorites/activities.py +++ b/api/funkwhale_api/favorites/activities.py @@ -1,19 +1,16 @@ -from funkwhale_api.common import channels from funkwhale_api.activity import record +from funkwhale_api.common import channels from . import serializers -record.registry.register_serializer( - serializers.TrackFavoriteActivitySerializer) +record.registry.register_serializer(serializers.TrackFavoriteActivitySerializer) -@record.registry.register_consumer('favorites.TrackFavorite') +@record.registry.register_consumer("favorites.TrackFavorite") def broadcast_track_favorite_to_instance_activity(data, obj): - if obj.user.privacy_level not in ['instance', 'everyone']: + if obj.user.privacy_level not in ["instance", "everyone"]: return - channels.group_send('instance_activity', { - 'type': 'event.send', - 'text': '', - 'data': data - }) + channels.group_send( + "instance_activity", {"type": "event.send", "text": "", "data": data} + ) diff --git a/api/funkwhale_api/favorites/admin.py b/api/funkwhale_api/favorites/admin.py index e8f29fac4..f56980e8c 100644 --- a/api/funkwhale_api/favorites/admin.py +++ b/api/funkwhale_api/favorites/admin.py @@ -5,8 +5,5 @@ from . import models @admin.register(models.TrackFavorite) class TrackFavoriteAdmin(admin.ModelAdmin): - list_display = ['user', 'track', 'creation_date'] - list_select_related = [ - 'user', - 'track' - ] + list_display = ["user", "track", "creation_date"] + list_select_related = ["user", "track"] diff --git a/api/funkwhale_api/favorites/factories.py b/api/funkwhale_api/favorites/factories.py index 233dd049c..d96ef1c15 100644 --- a/api/funkwhale_api/favorites/factories.py +++ b/api/funkwhale_api/favorites/factories.py @@ -1,7 +1,6 @@ import factory from funkwhale_api.factories import registry - from funkwhale_api.music.factories import TrackFactory from funkwhale_api.users.factories import UserFactory @@ -12,4 +11,4 @@ class TrackFavorite(factory.django.DjangoModelFactory): user = factory.SubFactory(UserFactory) class Meta: - model = 'favorites.TrackFavorite' + model = "favorites.TrackFavorite" diff --git a/api/funkwhale_api/favorites/migrations/0001_initial.py b/api/funkwhale_api/favorites/migrations/0001_initial.py index c2bd03182..17a66462e 100644 --- a/api/funkwhale_api/favorites/migrations/0001_initial.py +++ b/api/funkwhale_api/favorites/migrations/0001_initial.py @@ -9,25 +9,47 @@ from django.conf import settings class Migration(migrations.Migration): dependencies = [ - ('music', '0003_auto_20151222_2233'), + ("music", "0003_auto_20151222_2233"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='TrackFavorite', + name="TrackFavorite", fields=[ - ('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('track', models.ForeignKey(related_name='track_favorites', to='music.Track', on_delete=models.CASCADE)), - ('user', models.ForeignKey(related_name='track_favorites', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + serialize=False, + auto_created=True, + verbose_name="ID", + primary_key=True, + ), + ), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "track", + models.ForeignKey( + related_name="track_favorites", + to="music.Track", + on_delete=models.CASCADE, + ), + ), + ( + "user", + models.ForeignKey( + related_name="track_favorites", + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), ], - options={ - 'ordering': ('-creation_date',), - }, + options={"ordering": ("-creation_date",)}, ), migrations.AlterUniqueTogether( - name='trackfavorite', - unique_together=set([('track', 'user')]), + name="trackfavorite", unique_together=set([("track", "user")]) ), ] diff --git a/api/funkwhale_api/favorites/models.py b/api/funkwhale_api/favorites/models.py index 0c6a6b11c..a6a80cebd 100644 --- a/api/funkwhale_api/favorites/models.py +++ b/api/funkwhale_api/favorites/models.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.db import models from django.utils import timezone @@ -8,13 +7,15 @@ from funkwhale_api.music.models import Track class TrackFavorite(models.Model): creation_date = models.DateTimeField(default=timezone.now) user = models.ForeignKey( - 'users.User', related_name='track_favorites', on_delete=models.CASCADE) + "users.User", related_name="track_favorites", on_delete=models.CASCADE + ) track = models.ForeignKey( - Track, related_name='track_favorites', on_delete=models.CASCADE) + Track, related_name="track_favorites", on_delete=models.CASCADE + ) class Meta: - unique_together = ('track', 'user') - ordering = ('-creation_date',) + unique_together = ("track", "user") + ordering = ("-creation_date",) @classmethod def add(cls, track, user): @@ -22,5 +23,4 @@ class TrackFavorite(models.Model): return favorite def get_activity_url(self): - return '{}/favorites/tracks/{}'.format( - self.user.get_activity_url(), self.pk) + return "{}/favorites/tracks/{}".format(self.user.get_activity_url(), self.pk) diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py index bb4538b2d..3cafb80f0 100644 --- a/api/funkwhale_api/favorites/serializers.py +++ b/api/funkwhale_api/favorites/serializers.py @@ -1,4 +1,3 @@ -from django.conf import settings from rest_framework import serializers @@ -11,29 +10,22 @@ from . import models class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() - object = TrackActivitySerializer(source='track') - actor = UserActivitySerializer(source='user') - published = serializers.DateTimeField(source='creation_date') + object = TrackActivitySerializer(source="track") + actor = UserActivitySerializer(source="user") + published = serializers.DateTimeField(source="creation_date") class Meta: model = models.TrackFavorite - fields = [ - 'id', - 'local_id', - 'object', - 'type', - 'actor', - 'published' - ] + fields = ["id", "local_id", "object", "type", "actor", "published"] def get_actor(self, obj): return UserActivitySerializer(obj.user).data def get_type(self, obj): - return 'Like' + return "Like" class UserTrackFavoriteSerializer(serializers.ModelSerializer): class Meta: model = models.TrackFavorite - fields = ('id', 'track', 'creation_date') + fields = ("id", "track", "creation_date") diff --git a/api/funkwhale_api/favorites/urls.py b/api/funkwhale_api/favorites/urls.py index 6a9b12a81..28d0c8676 100644 --- a/api/funkwhale_api/favorites/urls.py +++ b/api/funkwhale_api/favorites/urls.py @@ -1,8 +1,8 @@ -from django.conf.urls import include, url +from rest_framework import routers + from . import views -from rest_framework import routers router = routers.SimpleRouter() -router.register(r'tracks', views.TrackFavoriteViewSet, 'tracks') +router.register(r"tracks", views.TrackFavoriteViewSet, "tracks") urlpatterns = router.urls diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index cd2aa3b61..4d1c1e756 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -1,24 +1,23 @@ -from rest_framework import generics, mixins, viewsets -from rest_framework import status -from rest_framework.response import Response -from rest_framework import pagination +from rest_framework import mixins, status, viewsets from rest_framework.decorators import list_route +from rest_framework.response import Response from funkwhale_api.activity import record -from funkwhale_api.music.models import Track from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.music.models import Track -from . import models -from . import serializers +from . import models, serializers -class TrackFavoriteViewSet(mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): +class TrackFavoriteViewSet( + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): serializer_class = serializers.UserTrackFavoriteSerializer - queryset = (models.TrackFavorite.objects.all()) + queryset = models.TrackFavorite.objects.all() permission_classes = [ConditionalAuthentication] def create(self, request, *args, **kwargs): @@ -28,20 +27,22 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin, serializer = self.get_serializer(instance=instance) headers = self.get_success_headers(serializer.data) record.send(instance) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) def get_queryset(self): return self.queryset.filter(user=self.request.user) def perform_create(self, serializer): - track = Track.objects.get(pk=serializer.data['track']) + track = Track.objects.get(pk=serializer.data["track"]) favorite = models.TrackFavorite.add(track=track, user=self.request.user) return favorite - @list_route(methods=['delete', 'post']) + @list_route(methods=["delete", "post"]) def remove(self, request, *args, **kwargs): try: - pk = int(request.data['track']) + pk = int(request.data["track"]) favorite = request.user.track_favorites.get(track__pk=pk) except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist): return Response({}, status=400) diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index becf6c96f..73e83e334 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -1,67 +1,61 @@ -from . import serializers -from . import tasks - ACTIVITY_TYPES = [ - 'Accept', - 'Add', - 'Announce', - 'Arrive', - 'Block', - 'Create', - 'Delete', - 'Dislike', - 'Flag', - 'Follow', - 'Ignore', - 'Invite', - 'Join', - 'Leave', - 'Like', - 'Listen', - 'Move', - 'Offer', - 'Question', - 'Reject', - 'Read', - 'Remove', - 'TentativeReject', - 'TentativeAccept', - 'Travel', - 'Undo', - 'Update', - 'View', + "Accept", + "Add", + "Announce", + "Arrive", + "Block", + "Create", + "Delete", + "Dislike", + "Flag", + "Follow", + "Ignore", + "Invite", + "Join", + "Leave", + "Like", + "Listen", + "Move", + "Offer", + "Question", + "Reject", + "Read", + "Remove", + "TentativeReject", + "TentativeAccept", + "Travel", + "Undo", + "Update", + "View", ] OBJECT_TYPES = [ - 'Article', - 'Audio', - 'Collection', - 'Document', - 'Event', - 'Image', - 'Note', - 'OrderedCollection', - 'Page', - 'Place', - 'Profile', - 'Relationship', - 'Tombstone', - 'Video', + "Article", + "Audio", + "Collection", + "Document", + "Event", + "Image", + "Note", + "OrderedCollection", + "Page", + "Place", + "Profile", + "Relationship", + "Tombstone", + "Video", ] + ACTIVITY_TYPES def deliver(activity, on_behalf_of, to=[]): - return tasks.send.delay( - activity=activity, - actor_id=on_behalf_of.pk, - to=to - ) + from . import tasks + + return tasks.send.delay(activity=activity, actor_id=on_behalf_of.pk, to=to) def accept_follow(follow): + from . import serializers + serializer = serializers.AcceptFollowSerializer(follow) - return deliver( - serializer.data, - to=[follow.actor.url], - on_behalf_of=follow.target) + return deliver(serializer.data, to=[follow.actor.url], on_behalf_of=follow.target) diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py index 7a209b1ff..7fbf815dc 100644 --- a/api/funkwhale_api/federation/actors.py +++ b/api/funkwhale_api/federation/actors.py @@ -1,36 +1,28 @@ import datetime import logging -import uuid import xml from django.conf import settings from django.db import transaction from django.urls import reverse from django.utils import timezone - from rest_framework.exceptions import PermissionDenied -from dynamic_preferences.registries import global_preferences_registry - -from funkwhale_api.common import preferences -from funkwhale_api.common import session +from funkwhale_api.common import preferences, session from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.music import models as music_models from funkwhale_api.music import tasks as music_tasks -from . import activity -from . import keys -from . import models -from . import serializers -from . import signing -from . import utils +from . import activity, keys, models, serializers, signing, utils logger = logging.getLogger(__name__) def remove_tags(text): - logger.debug('Removing tags from %s', text) - return ''.join(xml.etree.ElementTree.fromstring('
{}
'.format(text)).itertext()) + logger.debug("Removing tags from %s", text) + return "".join( + xml.etree.ElementTree.fromstring("
{}
".format(text)).itertext() + ) def get_actor_data(actor_url): @@ -38,16 +30,13 @@ def get_actor_data(actor_url): actor_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - headers={ - 'Accept': 'application/activity+json', - } + headers={"Accept": "application/activity+json"}, ) response.raise_for_status() try: return response.json() - except: - raise ValueError( - 'Invalid actor payload: {}'.format(response.text)) + except Exception: + raise ValueError("Invalid actor payload: {}".format(response.text)) def get_actor(actor_url): @@ -56,7 +45,8 @@ def get_actor(actor_url): except models.Actor.DoesNotExist: actor = None fetch_delta = datetime.timedelta( - minutes=preferences.get('federation__actor_fetch_delay')) + minutes=preferences.get("federation__actor_fetch_delay") + ) if actor and actor.last_fetch_date > timezone.now() - fetch_delta: # cache is hot, we can return as is return actor @@ -73,8 +63,7 @@ class SystemActor(object): def get_request_auth(self): actor = self.get_actor_instance() - return signing.get_auth( - actor.private_key, actor.private_key_id) + return signing.get_auth(actor.private_key, actor.private_key_id) def serialize(self): actor = self.get_actor_instance() @@ -88,42 +77,35 @@ class SystemActor(object): pass private, public = keys.get_key_pair() args = self.get_instance_argument( - self.id, - name=self.name, - summary=self.summary, - **self.additional_attributes + self.id, name=self.name, summary=self.summary, **self.additional_attributes ) - args['private_key'] = private.decode('utf-8') - args['public_key'] = public.decode('utf-8') + args["private_key"] = private.decode("utf-8") + args["public_key"] = public.decode("utf-8") return models.Actor.objects.create(**args) def get_actor_url(self): return utils.full_url( - reverse( - 'federation:instance-actors-detail', - kwargs={'actor': self.id})) + reverse("federation:instance-actors-detail", kwargs={"actor": self.id}) + ) def get_instance_argument(self, id, name, summary, **kwargs): p = { - 'preferred_username': id, - 'domain': settings.FEDERATION_HOSTNAME, - 'type': 'Person', - 'name': name.format(host=settings.FEDERATION_HOSTNAME), - 'manually_approves_followers': True, - 'url': self.get_actor_url(), - 'shared_inbox_url': utils.full_url( - reverse( - 'federation:instance-actors-inbox', - kwargs={'actor': id})), - 'inbox_url': utils.full_url( - reverse( - 'federation:instance-actors-inbox', - kwargs={'actor': id})), - 'outbox_url': utils.full_url( - reverse( - 'federation:instance-actors-outbox', - kwargs={'actor': id})), - 'summary': summary.format(host=settings.FEDERATION_HOSTNAME) + "preferred_username": id, + "domain": settings.FEDERATION_HOSTNAME, + "type": "Person", + "name": name.format(host=settings.FEDERATION_HOSTNAME), + "manually_approves_followers": True, + "url": self.get_actor_url(), + "shared_inbox_url": utils.full_url( + reverse("federation:instance-actors-inbox", kwargs={"actor": id}) + ), + "inbox_url": utils.full_url( + reverse("federation:instance-actors-inbox", kwargs={"actor": id}) + ), + "outbox_url": utils.full_url( + reverse("federation:instance-actors-outbox", kwargs={"actor": id}) + ), + "summary": summary.format(host=settings.FEDERATION_HOSTNAME), } p.update(kwargs) return p @@ -145,32 +127,29 @@ class SystemActor(object): Main entrypoint for handling activities posted to the actor's inbox """ - logger.info('Received activity on %s inbox', self.id) + logger.info("Received activity on %s inbox", self.id) if actor is None: - raise PermissionDenied('Actor not authenticated') + raise PermissionDenied("Actor not authenticated") - serializer = serializers.ActivitySerializer( - data=data, context={'actor': actor}) + serializer = serializers.ActivitySerializer(data=data, context={"actor": actor}) serializer.is_valid(raise_exception=True) ac = serializer.data try: - handler = getattr( - self, 'handle_{}'.format(ac['type'].lower())) + handler = getattr(self, "handle_{}".format(ac["type"].lower())) except (KeyError, AttributeError): - logger.debug( - 'No handler for activity %s', ac['type']) + logger.debug("No handler for activity %s", ac["type"]) return return handler(data, actor) def handle_follow(self, ac, sender): - system_actor = self.get_actor_instance() serializer = serializers.FollowSerializer( - data=ac, context={'follow_actor': sender}) + data=ac, context={"follow_actor": sender} + ) if not serializer.is_valid(): - return logger.info('Invalid follow payload') + return logger.info("Invalid follow payload") approved = True if not self.manually_approves_followers else None follow = serializer.save(approved=approved) if follow.approved: @@ -179,26 +158,27 @@ class SystemActor(object): def handle_accept(self, ac, sender): system_actor = self.get_actor_instance() serializer = serializers.AcceptFollowSerializer( - data=ac, - context={'follow_target': sender, 'follow_actor': system_actor}) + data=ac, context={"follow_target": sender, "follow_actor": system_actor} + ) if not serializer.is_valid(raise_exception=True): - return logger.info('Received invalid payload') + return logger.info("Received invalid payload") return serializer.save() def handle_undo_follow(self, ac, sender): system_actor = self.get_actor_instance() serializer = serializers.UndoFollowSerializer( - data=ac, context={'actor': sender, 'target': system_actor}) + data=ac, context={"actor": sender, "target": system_actor} + ) if not serializer.is_valid(): - return logger.info('Received invalid payload') + return logger.info("Received invalid payload") serializer.save() def handle_undo(self, ac, sender): - if ac['object']['type'] != 'Follow': + if ac["object"]["type"] != "Follow": return - if ac['object']['actor'] != sender.url: + if ac["object"]["actor"] != sender.url: # not the same actor, permission issue return @@ -206,55 +186,52 @@ class SystemActor(object): class LibraryActor(SystemActor): - id = 'library' - name = '{host}\'s library' - summary = 'Bot account to federate with {host}\'s library' - additional_attributes = { - 'manually_approves_followers': True - } + id = "library" + name = "{host}'s library" + summary = "Bot account to federate with {host}'s library" + additional_attributes = {"manually_approves_followers": True} def serialize(self): data = super().serialize() - urls = data.setdefault('url', []) - urls.append({ - 'type': 'Link', - 'mediaType': 'application/activity+json', - 'name': 'library', - 'href': utils.full_url(reverse('federation:music:files-list')) - }) + urls = data.setdefault("url", []) + urls.append( + { + "type": "Link", + "mediaType": "application/activity+json", + "name": "library", + "href": utils.full_url(reverse("federation:music:files-list")), + } + ) return data @property def manually_approves_followers(self): - return preferences.get('federation__music_needs_approval') + return preferences.get("federation__music_needs_approval") @transaction.atomic def handle_create(self, ac, sender): try: remote_library = models.Library.objects.get( - actor=sender, - federation_enabled=True, + actor=sender, federation_enabled=True ) except models.Library.DoesNotExist: - logger.info( - 'Skipping import, we\'re not following %s', sender.url) + logger.info("Skipping import, we're not following %s", sender.url) return - if ac['object']['type'] != 'Collection': + if ac["object"]["type"] != "Collection": return - if ac['object']['totalItems'] <= 0: + if ac["object"]["totalItems"] <= 0: return try: - items = ac['object']['items'] + items = ac["object"]["items"] except KeyError: - logger.warning('No items in collection!') + logger.warning("No items in collection!") return item_serializers = [ - serializers.AudioSerializer( - data=i, context={'library': remote_library}) + serializers.AudioSerializer(data=i, context={"library": remote_library}) for i in items ] now = timezone.now() @@ -263,27 +240,21 @@ class LibraryActor(SystemActor): if s.is_valid(): valid_serializers.append(s) else: - logger.debug( - 'Skipping invalid item %s, %s', s.initial_data, s.errors) + logger.debug("Skipping invalid item %s, %s", s.initial_data, s.errors) lts = [] for s in valid_serializers: lts.append(s.save()) if remote_library.autoimport: - batch = music_models.ImportBatch.objects.create( - source='federation', - ) + batch = music_models.ImportBatch.objects.create(source="federation") for lt in lts: if lt.creation_date < now: # track was already in the library, we do not trigger # an import continue job = music_models.ImportJob.objects.create( - batch=batch, - library_track=lt, - mbid=lt.mbid, - source=lt.url, + batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url ) funkwhale_utils.on_commit( music_tasks.import_job_run.delay, @@ -293,15 +264,13 @@ class LibraryActor(SystemActor): class TestActor(SystemActor): - id = 'test' - name = '{host}\'s test account' + id = "test" + name = "{host}'s test account" summary = ( - 'Bot account to test federation with {host}. ' - 'Send me /ping and I\'ll answer you.' + "Bot account to test federation with {host}. " + "Send me /ping and I'll answer you." ) - additional_attributes = { - 'manually_approves_followers': False - } + additional_attributes = {"manually_approves_followers": False} manually_approves_followers = False def get_outbox(self, data, actor=None): @@ -309,15 +278,14 @@ class TestActor(SystemActor): "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", - {} + {}, ], "id": utils.full_url( - reverse( - 'federation:instance-actors-outbox', - kwargs={'actor': self.id})), + reverse("federation:instance-actors-outbox", kwargs={"actor": self.id}) + ), "type": "OrderedCollection", "totalItems": 0, - "orderedItems": [] + "orderedItems": [], } def parse_command(self, message): @@ -327,99 +295,85 @@ class TestActor(SystemActor): """ raw = remove_tags(message) try: - return raw.split('/')[1] + return raw.split("/")[1] except IndexError: return def handle_create(self, ac, sender): - if ac['object']['type'] != 'Note': + if ac["object"]["type"] != "Note": return # we received a toot \o/ - command = self.parse_command(ac['object']['content']) - logger.debug('Parsed command: %s', command) - if command != 'ping': + command = self.parse_command(ac["object"]["content"]) + logger.debug("Parsed command: %s", command) + if command != "ping": return now = timezone.now() test_actor = self.get_actor_instance() - reply_url = 'https://{}/activities/note/{}'.format( + reply_url = "https://{}/activities/note/{}".format( settings.FEDERATION_HOSTNAME, now.timestamp() ) - reply_content = '{} Pong!'.format( - sender.mention_username - ) reply_activity = { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", - {} + {}, ], - 'type': 'Create', - 'actor': test_actor.url, - 'id': '{}/activity'.format(reply_url), - 'published': now.isoformat(), - 'to': ac['actor'], - 'cc': [], - 'object': { - 'type': 'Note', - 'content': 'Pong!', - 'summary': None, - 'published': now.isoformat(), - 'id': reply_url, - 'inReplyTo': ac['object']['id'], - 'sensitive': False, - 'url': reply_url, - 'to': [ac['actor']], - 'attributedTo': test_actor.url, - 'cc': [], - 'attachment': [], - 'tag': [{ - "type": "Mention", - "href": ac['actor'], - "name": sender.mention_username - }] - } + "type": "Create", + "actor": test_actor.url, + "id": "{}/activity".format(reply_url), + "published": now.isoformat(), + "to": ac["actor"], + "cc": [], + "object": { + "type": "Note", + "content": "Pong!", + "summary": None, + "published": now.isoformat(), + "id": reply_url, + "inReplyTo": ac["object"]["id"], + "sensitive": False, + "url": reply_url, + "to": [ac["actor"]], + "attributedTo": test_actor.url, + "cc": [], + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": ac["actor"], + "name": sender.mention_username, + } + ], + }, } - activity.deliver( - reply_activity, - to=[ac['actor']], - on_behalf_of=test_actor) + activity.deliver(reply_activity, to=[ac["actor"]], on_behalf_of=test_actor) def handle_follow(self, ac, sender): super().handle_follow(ac, sender) # also, we follow back test_actor = self.get_actor_instance() follow_back = models.Follow.objects.get_or_create( - actor=test_actor, - target=sender, - approved=None, + actor=test_actor, target=sender, approved=None )[0] activity.deliver( serializers.FollowSerializer(follow_back).data, to=[follow_back.target.url], - on_behalf_of=follow_back.actor) + on_behalf_of=follow_back.actor, + ) def handle_undo_follow(self, ac, sender): super().handle_undo_follow(ac, sender) actor = self.get_actor_instance() # we also unfollow the sender, if possible try: - follow = models.Follow.objects.get( - target=sender, - actor=actor, - ) + follow = models.Follow.objects.get(target=sender, actor=actor) except models.Follow.DoesNotExist: return undo = serializers.UndoFollowSerializer(follow).data follow.delete() - activity.deliver( - undo, - to=[sender.url], - on_behalf_of=actor) + activity.deliver(undo, to=[sender.url], on_behalf_of=actor) -SYSTEM_ACTORS = { - 'library': LibraryActor(), - 'test': TestActor(), -} +SYSTEM_ACTORS = {"library": LibraryActor(), "test": TestActor()} diff --git a/api/funkwhale_api/federation/admin.py b/api/funkwhale_api/federation/admin.py index 6a097174b..a82e9aaf2 100644 --- a/api/funkwhale_api/federation/admin.py +++ b/api/funkwhale_api/federation/admin.py @@ -6,61 +6,43 @@ from . import models @admin.register(models.Actor) class ActorAdmin(admin.ModelAdmin): list_display = [ - 'url', - 'domain', - 'preferred_username', - 'type', - 'creation_date', - 'last_fetch_date'] - search_fields = ['url', 'domain', 'preferred_username'] - list_filter = [ - 'type' + "url", + "domain", + "preferred_username", + "type", + "creation_date", + "last_fetch_date", ] + search_fields = ["url", "domain", "preferred_username"] + list_filter = ["type"] @admin.register(models.Follow) class FollowAdmin(admin.ModelAdmin): - list_display = [ - 'actor', - 'target', - 'approved', - 'creation_date' - ] - list_filter = [ - 'approved' - ] - search_fields = ['actor__url', 'target__url'] + list_display = ["actor", "target", "approved", "creation_date"] + list_filter = ["approved"] + search_fields = ["actor__url", "target__url"] list_select_related = True @admin.register(models.Library) class LibraryAdmin(admin.ModelAdmin): - list_display = [ - 'actor', - 'url', - 'creation_date', - 'fetched_date', - 'tracks_count'] - search_fields = ['actor__url', 'url'] - list_filter = [ - 'federation_enabled', - 'download_files', - 'autoimport', - ] + list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"] + search_fields = ["actor__url", "url"] + list_filter = ["federation_enabled", "download_files", "autoimport"] list_select_related = True @admin.register(models.LibraryTrack) class LibraryTrackAdmin(admin.ModelAdmin): list_display = [ - 'title', - 'artist_name', - 'album_title', - 'url', - 'library', - 'creation_date', - 'published_date', + "title", + "artist_name", + "album_title", + "url", + "library", + "creation_date", + "published_date", ] - search_fields = [ - 'library__url', 'url', 'artist_name', 'title', 'album_title'] + search_fields = ["library__url", "url", "artist_name", "title", "album_title"] list_select_related = True diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py index 41dd1c0f9..625043bf6 100644 --- a/api/funkwhale_api/federation/api_urls.py +++ b/api/funkwhale_api/federation/api_urls.py @@ -3,13 +3,7 @@ from rest_framework import routers from . import views router = routers.SimpleRouter() -router.register( - r'libraries', - views.LibraryViewSet, - 'libraries') -router.register( - r'library-tracks', - views.LibraryTrackViewSet, - 'library-tracks') +router.register(r"libraries", views.LibraryViewSet, "libraries") +router.register(r"library-tracks", views.LibraryTrackViewSet, "library-tracks") urlpatterns = router.urls diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py index bfd46084c..f32c78ff3 100644 --- a/api/funkwhale_api/federation/authentication.py +++ b/api/funkwhale_api/federation/authentication.py @@ -1,23 +1,15 @@ import cryptography - from django.contrib.auth.models import AnonymousUser +from rest_framework import authentication, exceptions -from rest_framework import authentication -from rest_framework import exceptions - -from . import actors -from . import keys -from . import models -from . import serializers -from . import signing -from . import utils +from . import actors, keys, signing, utils class SignatureAuthentication(authentication.BaseAuthentication): def authenticate_actor(self, request): headers = utils.clean_wsgi_headers(request.META) try: - signature = headers['Signature'] + signature = headers["Signature"] key_id = keys.get_key_id_from_signature_header(signature) except KeyError: return @@ -25,25 +17,25 @@ class SignatureAuthentication(authentication.BaseAuthentication): raise exceptions.AuthenticationFailed(str(e)) try: - actor = actors.get_actor(key_id.split('#')[0]) + actor = actors.get_actor(key_id.split("#")[0]) except Exception as e: raise exceptions.AuthenticationFailed(str(e)) if not actor.public_key: - raise exceptions.AuthenticationFailed('No public key found') + raise exceptions.AuthenticationFailed("No public key found") try: - signing.verify_django(request, actor.public_key.encode('utf-8')) + signing.verify_django(request, actor.public_key.encode("utf-8")) except cryptography.exceptions.InvalidSignature: - raise exceptions.AuthenticationFailed('Invalid signature') + raise exceptions.AuthenticationFailed("Invalid signature") return actor def authenticate(self, request): - setattr(request, 'actor', None) + setattr(request, "actor", None) actor = self.authenticate_actor(request) if not actor: return user = AnonymousUser() - setattr(request, 'actor', actor) + setattr(request, "actor", actor) return (user, None) diff --git a/api/funkwhale_api/federation/dynamic_preferences_registry.py b/api/funkwhale_api/federation/dynamic_preferences_registry.py index 8b1b2b03f..5119d2596 100644 --- a/api/funkwhale_api/federation/dynamic_preferences_registry.py +++ b/api/funkwhale_api/federation/dynamic_preferences_registry.py @@ -1,80 +1,68 @@ -from django.forms import widgets from dynamic_preferences import types from dynamic_preferences.registries import global_preferences_registry from funkwhale_api.common import preferences -federation = types.Section('federation') + +federation = types.Section("federation") @global_preferences_registry.register class MusicCacheDuration(types.IntPreference): show_in_api = True section = federation - name = 'music_cache_duration' + name = "music_cache_duration" default = 60 * 24 * 2 - verbose_name = 'Music cache duration' + verbose_name = "Music cache duration" help_text = ( - 'How much minutes do you want to keep a copy of federated tracks' - 'locally? Federated files that were not listened in this interval ' - 'will be erased and refetched from the remote on the next listening.' + "How much minutes do you want to keep a copy of federated tracks" + "locally? Federated files that were not listened in this interval " + "will be erased and refetched from the remote on the next listening." ) - field_kwargs = { - 'required': False, - } + field_kwargs = {"required": False} @global_preferences_registry.register class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference): section = federation - name = 'enabled' - setting = 'FEDERATION_ENABLED' - verbose_name = 'Federation enabled' + name = "enabled" + setting = "FEDERATION_ENABLED" + verbose_name = "Federation enabled" help_text = ( - 'Use this setting to enable or disable federation logic and API' - ' globally.' + "Use this setting to enable or disable federation logic and API" " globally." ) @global_preferences_registry.register -class CollectionPageSize( - preferences.DefaultFromSettingMixin, types.IntPreference): +class CollectionPageSize(preferences.DefaultFromSettingMixin, types.IntPreference): section = federation - name = 'collection_page_size' - setting = 'FEDERATION_COLLECTION_PAGE_SIZE' - verbose_name = 'Federation collection page size' - help_text = ( - 'How much items to display in ActivityPub collections.' - ) - field_kwargs = { - 'required': False, - } + name = "collection_page_size" + setting = "FEDERATION_COLLECTION_PAGE_SIZE" + verbose_name = "Federation collection page size" + help_text = "How much items to display in ActivityPub collections." + field_kwargs = {"required": False} @global_preferences_registry.register -class ActorFetchDelay( - preferences.DefaultFromSettingMixin, types.IntPreference): +class ActorFetchDelay(preferences.DefaultFromSettingMixin, types.IntPreference): section = federation - name = 'actor_fetch_delay' - setting = 'FEDERATION_ACTOR_FETCH_DELAY' - verbose_name = 'Federation actor fetch delay' + name = "actor_fetch_delay" + setting = "FEDERATION_ACTOR_FETCH_DELAY" + verbose_name = "Federation actor fetch delay" help_text = ( - 'How much minutes to wait before refetching actors on ' - 'request authentication.' + "How much minutes to wait before refetching actors on " + "request authentication." ) - field_kwargs = { - 'required': False, - } + field_kwargs = {"required": False} @global_preferences_registry.register -class MusicNeedsApproval( - preferences.DefaultFromSettingMixin, types.BooleanPreference): +class MusicNeedsApproval(preferences.DefaultFromSettingMixin, types.BooleanPreference): section = federation - name = 'music_needs_approval' - setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL' - verbose_name = 'Federation music needs approval' + name = "music_needs_approval" + setting = "FEDERATION_MUSIC_NEEDS_APPROVAL" + verbose_name = "Federation music needs approval" help_text = ( - 'When true, other federation actors will need your approval' - ' before being able to browse your library.' + "When true, other federation actors will need your approval" + " before being able to browse your library." ) diff --git a/api/funkwhale_api/federation/exceptions.py b/api/funkwhale_api/federation/exceptions.py index 31d864b36..b3fb73ab8 100644 --- a/api/funkwhale_api/federation/exceptions.py +++ b/api/funkwhale_api/federation/exceptions.py @@ -1,5 +1,3 @@ - - class MalformedPayload(ValueError): pass diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index 891609cba..7370ebd77 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -1,40 +1,34 @@ +import uuid + import factory import requests import requests_http_signature -import uuid - -from django.utils import timezone from django.conf import settings +from django.utils import timezone from funkwhale_api.factories import registry -from . import keys -from . import models +from . import keys, models + +registry.register(keys.get_key_pair, name="federation.KeyPair") -registry.register(keys.get_key_pair, name='federation.KeyPair') - - -@registry.register(name='federation.SignatureAuth') +@registry.register(name="federation.SignatureAuth") class SignatureAuthFactory(factory.Factory): - algorithm = 'rsa-sha256' + algorithm = "rsa-sha256" key = factory.LazyFunction(lambda: keys.get_key_pair()[0]) - key_id = factory.Faker('url') + key_id = factory.Faker("url") use_auth_header = False - headers = [ - '(request-target)', - 'user-agent', - 'host', - 'date', - 'content-type',] + headers = ["(request-target)", "user-agent", "host", "date", "content-type"] + class Meta: model = requests_http_signature.HTTPSignatureAuth -@registry.register(name='federation.SignedRequest') +@registry.register(name="federation.SignedRequest") class SignedRequestFactory(factory.Factory): - url = factory.Faker('url') - method = 'get' + url = factory.Faker("url") + method = "get" auth = factory.SubFactory(SignatureAuthFactory) class Meta: @@ -43,59 +37,62 @@ class SignedRequestFactory(factory.Factory): @factory.post_generation def headers(self, create, extracted, **kwargs): default_headers = { - 'User-Agent': 'Test', - 'Host': 'test.host', - 'Date': 'Right now', - 'Content-Type': 'application/activity+json' + "User-Agent": "Test", + "Host": "test.host", + "Date": "Right now", + "Content-Type": "application/activity+json", } if extracted: default_headers.update(extracted) self.headers.update(default_headers) -@registry.register(name='federation.Link') +@registry.register(name="federation.Link") class LinkFactory(factory.Factory): - type = 'Link' - href = factory.Faker('url') - mediaType = 'text/html' + type = "Link" + href = factory.Faker("url") + mediaType = "text/html" class Meta: model = dict class Params: - audio = factory.Trait( - mediaType=factory.Iterator(['audio/mp3', 'audio/ogg']) - ) + audio = factory.Trait(mediaType=factory.Iterator(["audio/mp3", "audio/ogg"])) @registry.register class ActorFactory(factory.DjangoModelFactory): public_key = None private_key = None - preferred_username = factory.Faker('user_name') - summary = factory.Faker('paragraph') - domain = factory.Faker('domain_name') - url = factory.LazyAttribute(lambda o: 'https://{}/users/{}'.format(o.domain, o.preferred_username)) - inbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/inbox'.format(o.domain, o.preferred_username)) - outbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/outbox'.format(o.domain, o.preferred_username)) + preferred_username = factory.Faker("user_name") + summary = factory.Faker("paragraph") + domain = factory.Faker("domain_name") + url = factory.LazyAttribute( + lambda o: "https://{}/users/{}".format(o.domain, o.preferred_username) + ) + inbox_url = factory.LazyAttribute( + lambda o: "https://{}/users/{}/inbox".format(o.domain, o.preferred_username) + ) + outbox_url = factory.LazyAttribute( + lambda o: "https://{}/users/{}/outbox".format(o.domain, o.preferred_username) + ) class Meta: model = models.Actor class Params: local = factory.Trait( - domain=factory.LazyAttribute( - lambda o: settings.FEDERATION_HOSTNAME) + domain=factory.LazyAttribute(lambda o: settings.FEDERATION_HOSTNAME) ) @classmethod def _generate(cls, create, attrs): - has_public = attrs.get('public_key') is not None - has_private = attrs.get('private_key') is not None + has_public = attrs.get("public_key") is not None + has_private = attrs.get("private_key") is not None if not has_public and not has_private: private, public = keys.get_key_pair() - attrs['private_key'] = private.decode('utf-8') - attrs['public_key'] = public.decode('utf-8') + attrs["private_key"] = private.decode("utf-8") + attrs["public_key"] = public.decode("utf-8") return super()._generate(create, attrs) @@ -108,15 +105,13 @@ class FollowFactory(factory.DjangoModelFactory): model = models.Follow class Params: - local = factory.Trait( - actor=factory.SubFactory(ActorFactory, local=True) - ) + local = factory.Trait(actor=factory.SubFactory(ActorFactory, local=True)) @registry.register class LibraryFactory(factory.DjangoModelFactory): actor = factory.SubFactory(ActorFactory) - url = factory.Faker('url') + url = factory.Faker("url") federation_enabled = True download_files = False autoimport = False @@ -126,42 +121,36 @@ class LibraryFactory(factory.DjangoModelFactory): class ArtistMetadataFactory(factory.Factory): - name = factory.Faker('name') + name = factory.Faker("name") class Meta: model = dict class Params: - musicbrainz = factory.Trait( - musicbrainz_id=factory.Faker('uuid4') - ) + musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4")) class ReleaseMetadataFactory(factory.Factory): - title = factory.Faker('sentence') + title = factory.Faker("sentence") class Meta: model = dict class Params: - musicbrainz = factory.Trait( - musicbrainz_id=factory.Faker('uuid4') - ) + musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4")) class RecordingMetadataFactory(factory.Factory): - title = factory.Faker('sentence') + title = factory.Faker("sentence") class Meta: model = dict class Params: - musicbrainz = factory.Trait( - musicbrainz_id=factory.Faker('uuid4') - ) + musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4")) -@registry.register(name='federation.LibraryTrackMetadata') +@registry.register(name="federation.LibraryTrackMetadata") class LibraryTrackMetadataFactory(factory.Factory): artist = factory.SubFactory(ArtistMetadataFactory) recording = factory.SubFactory(RecordingMetadataFactory) @@ -174,64 +163,59 @@ class LibraryTrackMetadataFactory(factory.Factory): @registry.register class LibraryTrackFactory(factory.DjangoModelFactory): library = factory.SubFactory(LibraryFactory) - url = factory.Faker('url') - title = factory.Faker('sentence') - artist_name = factory.Faker('sentence') - album_title = factory.Faker('sentence') - audio_url = factory.Faker('url') - audio_mimetype = 'audio/ogg' + url = factory.Faker("url") + title = factory.Faker("sentence") + artist_name = factory.Faker("sentence") + album_title = factory.Faker("sentence") + audio_url = factory.Faker("url") + audio_mimetype = "audio/ogg" metadata = factory.SubFactory(LibraryTrackMetadataFactory) class Meta: model = models.LibraryTrack class Params: - with_audio_file = factory.Trait( - audio_file=factory.django.FileField() - ) + with_audio_file = factory.Trait(audio_file=factory.django.FileField()) -@registry.register(name='federation.Note') +@registry.register(name="federation.Note") class NoteFactory(factory.Factory): - type = 'Note' - id = factory.Faker('url') - published = factory.LazyFunction( - lambda: timezone.now().isoformat() - ) + type = "Note" + id = factory.Faker("url") + published = factory.LazyFunction(lambda: timezone.now().isoformat()) inReplyTo = None - content = factory.Faker('sentence') + content = factory.Faker("sentence") class Meta: model = dict -@registry.register(name='federation.Activity') +@registry.register(name="federation.Activity") class ActivityFactory(factory.Factory): - type = 'Create' - id = factory.Faker('url') - published = factory.LazyFunction( - lambda: timezone.now().isoformat() - ) - actor = factory.Faker('url') + type = "Create" + id = factory.Faker("url") + published = factory.LazyFunction(lambda: timezone.now().isoformat()) + actor = factory.Faker("url") object = factory.SubFactory( NoteFactory, - actor=factory.SelfAttribute('..actor'), - published=factory.SelfAttribute('..published')) + actor=factory.SelfAttribute("..actor"), + published=factory.SelfAttribute("..published"), + ) class Meta: model = dict -@registry.register(name='federation.AudioMetadata') +@registry.register(name="federation.AudioMetadata") class AudioMetadataFactory(factory.Factory): recording = factory.LazyAttribute( - lambda o: 'https://musicbrainz.org/recording/{}'.format(uuid.uuid4()) + lambda o: "https://musicbrainz.org/recording/{}".format(uuid.uuid4()) ) artist = factory.LazyAttribute( - lambda o: 'https://musicbrainz.org/artist/{}'.format(uuid.uuid4()) + lambda o: "https://musicbrainz.org/artist/{}".format(uuid.uuid4()) ) release = factory.LazyAttribute( - lambda o: 'https://musicbrainz.org/release/{}'.format(uuid.uuid4()) + lambda o: "https://musicbrainz.org/release/{}".format(uuid.uuid4()) ) bitrate = 42 length = 43 @@ -241,14 +225,12 @@ class AudioMetadataFactory(factory.Factory): model = dict -@registry.register(name='federation.Audio') +@registry.register(name="federation.Audio") class AudioFactory(factory.Factory): - type = 'Audio' - id = factory.Faker('url') - published = factory.LazyFunction( - lambda: timezone.now().isoformat() - ) - actor = factory.Faker('url') + type = "Audio" + id = factory.Faker("url") + published = factory.LazyFunction(lambda: timezone.now().isoformat()) + actor = factory.Faker("url") url = factory.SubFactory(LinkFactory, audio=True) metadata = factory.SubFactory(LibraryTrackMetadataFactory) diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py index 1d93f68b9..3b5bfd739 100644 --- a/api/funkwhale_api/federation/filters.py +++ b/api/funkwhale_api/federation/filters.py @@ -6,73 +6,67 @@ from . import models class LibraryFilter(django_filters.FilterSet): - approved = django_filters.BooleanFilter('following__approved') - q = fields.SearchFilter(search_fields=[ - 'actor__domain', - ]) + approved = django_filters.BooleanFilter("following__approved") + q = fields.SearchFilter(search_fields=["actor__domain"]) class Meta: model = models.Library fields = { - 'approved': ['exact'], - 'federation_enabled': ['exact'], - 'download_files': ['exact'], - 'autoimport': ['exact'], - 'tracks_count': ['exact'], + "approved": ["exact"], + "federation_enabled": ["exact"], + "download_files": ["exact"], + "autoimport": ["exact"], + "tracks_count": ["exact"], } class LibraryTrackFilter(django_filters.FilterSet): - library = django_filters.CharFilter('library__uuid') - status = django_filters.CharFilter(method='filter_status') - q = fields.SearchFilter(search_fields=[ - 'artist_name', - 'title', - 'album_title', - 'library__actor__domain', - ]) + library = django_filters.CharFilter("library__uuid") + status = django_filters.CharFilter(method="filter_status") + q = fields.SearchFilter( + search_fields=["artist_name", "title", "album_title", "library__actor__domain"] + ) def filter_status(self, queryset, field_name, value): - if value == 'imported': + if value == "imported": return queryset.filter(local_track_file__isnull=False) - elif value == 'not_imported': - return queryset.filter( - local_track_file__isnull=True - ).exclude(import_jobs__status='pending') - elif value == 'import_pending': - return queryset.filter(import_jobs__status='pending') + elif value == "not_imported": + return queryset.filter(local_track_file__isnull=True).exclude( + import_jobs__status="pending" + ) + elif value == "import_pending": + return queryset.filter(import_jobs__status="pending") return queryset class Meta: model = models.LibraryTrack fields = { - 'library': ['exact'], - 'artist_name': ['exact', 'icontains'], - 'title': ['exact', 'icontains'], - 'album_title': ['exact', 'icontains'], - 'audio_mimetype': ['exact', 'icontains'], + "library": ["exact"], + "artist_name": ["exact", "icontains"], + "title": ["exact", "icontains"], + "album_title": ["exact", "icontains"], + "audio_mimetype": ["exact", "icontains"], } class FollowFilter(django_filters.FilterSet): - pending = django_filters.CharFilter(method='filter_pending') + pending = django_filters.CharFilter(method="filter_pending") ordering = django_filters.OrderingFilter( # tuple-mapping retains order fields=( - ('creation_date', 'creation_date'), - ('modification_date', 'modification_date'), - ), + ("creation_date", "creation_date"), + ("modification_date", "modification_date"), + ) + ) + q = fields.SearchFilter( + search_fields=["actor__domain", "actor__preferred_username"] ) - q = fields.SearchFilter(search_fields=[ - 'actor__domain', - 'actor__preferred_username', - ]) class Meta: model = models.Follow - fields = ['approved', 'pending', 'q'] + fields = ["approved", "pending", "q"] def filter_pending(self, queryset, field_name, value): - if value.lower() in ['true', '1', 'yes']: + if value.lower() in ["true", "1", "yes"]: queryset = queryset.filter(approved__isnull=True) return queryset diff --git a/api/funkwhale_api/federation/keys.py b/api/funkwhale_api/federation/keys.py index 7e9d316c2..e7c30c50a 100644 --- a/api/funkwhale_api/federation/keys.py +++ b/api/funkwhale_api/federation/keys.py @@ -1,48 +1,44 @@ -from cryptography.hazmat.primitives import serialization as crypto_serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.backends import default_backend as crypto_default_backend - import re import urllib.parse -from . import exceptions +from cryptography.hazmat.backends import default_backend as crypto_default_backend +from cryptography.hazmat.primitives import serialization as crypto_serialization +from cryptography.hazmat.primitives.asymmetric import rsa -KEY_ID_REGEX = re.compile(r'keyId=\"(?P.*)\"') +KEY_ID_REGEX = re.compile(r"keyId=\"(?P.*)\"") def get_key_pair(size=2048): key = rsa.generate_private_key( - backend=crypto_default_backend(), - public_exponent=65537, - key_size=size + backend=crypto_default_backend(), public_exponent=65537, key_size=size ) private_key = key.private_bytes( crypto_serialization.Encoding.PEM, crypto_serialization.PrivateFormat.PKCS8, - crypto_serialization.NoEncryption()) + crypto_serialization.NoEncryption(), + ) public_key = key.public_key().public_bytes( - crypto_serialization.Encoding.PEM, - crypto_serialization.PublicFormat.PKCS1 + crypto_serialization.Encoding.PEM, crypto_serialization.PublicFormat.PKCS1 ) return private_key, public_key def get_key_id_from_signature_header(header_string): - parts = header_string.split(',') + parts = header_string.split(",") try: raw_key_id = [p for p in parts if p.startswith('keyId="')][0] except IndexError: - raise ValueError('Missing key id') + raise ValueError("Missing key id") match = KEY_ID_REGEX.match(raw_key_id) if not match: - raise ValueError('Invalid key id') + raise ValueError("Invalid key id") key_id = match.groups()[0] url = urllib.parse.urlparse(key_id) if not url.scheme or not url.netloc: - raise ValueError('Invalid url') - if url.scheme not in ['http', 'https']: - raise ValueError('Invalid shceme') + raise ValueError("Invalid url") + if url.scheme not in ["http", "https"]: + raise ValueError("Invalid shceme") return key_id diff --git a/api/funkwhale_api/federation/library.py b/api/funkwhale_api/federation/library.py index c53ce5430..d2ccb1952 100644 --- a/api/funkwhale_api/federation/library.py +++ b/api/funkwhale_api/federation/library.py @@ -1,15 +1,11 @@ import json -import requests +import requests from django.conf import settings from funkwhale_api.common import session -from . import actors -from . import models -from . import serializers -from . import signing -from . import webfinger +from . import actors, models, serializers, signing, webfinger def scan_from_account_name(account_name): @@ -24,87 +20,59 @@ def scan_from_account_name(account_name): """ data = {} try: - username, domain = webfinger.clean_acct( - account_name, ensure_local=False) + username, domain = webfinger.clean_acct(account_name, ensure_local=False) except serializers.ValidationError: - return { - 'webfinger': { - 'errors': ['Invalid account string'] - } - } - system_library = actors.SYSTEM_ACTORS['library'].get_actor_instance() - library = models.Library.objects.filter( - actor__domain=domain, - actor__preferred_username=username - ).select_related('actor').first() - data['local'] = { - 'following': False, - 'awaiting_approval': False, - } + return {"webfinger": {"errors": ["Invalid account string"]}} + system_library = actors.SYSTEM_ACTORS["library"].get_actor_instance() + data["local"] = {"following": False, "awaiting_approval": False} try: follow = models.Follow.objects.get( target__preferred_username=username, target__domain=username, actor=system_library, ) - data['local']['awaiting_approval'] = not bool(follow.approved) - data['local']['following'] = True + data["local"]["awaiting_approval"] = not bool(follow.approved) + data["local"]["following"] = True except models.Follow.DoesNotExist: pass try: - data['webfinger'] = webfinger.get_resource( - 'acct:{}'.format(account_name)) + data["webfinger"] = webfinger.get_resource("acct:{}".format(account_name)) except requests.ConnectionError: - return { - 'webfinger': { - 'errors': ['This webfinger resource is not reachable'] - } - } + return {"webfinger": {"errors": ["This webfinger resource is not reachable"]}} except requests.HTTPError as e: return { - 'webfinger': { - 'errors': [ - 'Error {} during webfinger request'.format( - e.response.status_code)] + "webfinger": { + "errors": [ + "Error {} during webfinger request".format(e.response.status_code) + ] } } except json.JSONDecodeError as e: - return { - 'webfinger': { - 'errors': ['Could not process webfinger response'] - } - } + return {"webfinger": {"errors": ["Could not process webfinger response"]}} try: - data['actor'] = actors.get_actor_data(data['webfinger']['actor_url']) + data["actor"] = actors.get_actor_data(data["webfinger"]["actor_url"]) except requests.ConnectionError: - data['actor'] = { - 'errors': ['This actor is not reachable'] - } + data["actor"] = {"errors": ["This actor is not reachable"]} return data except requests.HTTPError as e: - data['actor'] = { - 'errors': [ - 'Error {} during actor request'.format( - e.response.status_code)] + data["actor"] = { + "errors": ["Error {} during actor request".format(e.response.status_code)] } return data - serializer = serializers.LibraryActorSerializer(data=data['actor']) + serializer = serializers.LibraryActorSerializer(data=data["actor"]) if not serializer.is_valid(): - data['actor'] = { - 'errors': ['Invalid ActivityPub actor'] - } + data["actor"] = {"errors": ["Invalid ActivityPub actor"]} return data - data['library'] = get_library_data( - serializer.validated_data['library_url']) + data["library"] = get_library_data(serializer.validated_data["library_url"]) return data def get_library_data(library_url): - actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() auth = signing.get_auth(actor.private_key, actor.private_key_id) try: response = session.get_session().get( @@ -112,55 +80,37 @@ def get_library_data(library_url): auth=auth, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - headers={ - 'Content-Type': 'application/activity+json' - } + headers={"Content-Type": "application/activity+json"}, ) except requests.ConnectionError: - return { - 'errors': ['This library is not reachable'] - } + return {"errors": ["This library is not reachable"]} scode = response.status_code if scode == 401: - return { - 'errors': ['This library requires authentication'] - } + return {"errors": ["This library requires authentication"]} elif scode == 403: - return { - 'errors': ['Permission denied while scanning library'] - } + return {"errors": ["Permission denied while scanning library"]} elif scode >= 400: - return { - 'errors': ['Error {} while fetching the library'.format(scode)] - } - serializer = serializers.PaginatedCollectionSerializer( - data=response.json(), - ) + return {"errors": ["Error {} while fetching the library".format(scode)]} + serializer = serializers.PaginatedCollectionSerializer(data=response.json()) if not serializer.is_valid(): - return { - 'errors': [ - 'Invalid ActivityPub response from remote library'] - } + return {"errors": ["Invalid ActivityPub response from remote library"]} return serializer.validated_data def get_library_page(library, page_url): - actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() auth = signing.get_auth(actor.private_key, actor.private_key_id) response = session.get_session().get( page_url, auth=auth, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - headers={ - 'Content-Type': 'application/activity+json' - } + headers={"Content-Type": "application/activity+json"}, ) serializer = serializers.CollectionPageSerializer( data=response.json(), - context={ - 'library': library, - 'item_serializer': serializers.AudioSerializer}) + context={"library": library, "item_serializer": serializers.AudioSerializer}, + ) serializer.is_valid(raise_exception=True) return serializer.validated_data diff --git a/api/funkwhale_api/federation/migrations/0001_initial.py b/api/funkwhale_api/federation/migrations/0001_initial.py index a9157e57e..a4c641b4e 100644 --- a/api/funkwhale_api/federation/migrations/0001_initial.py +++ b/api/funkwhale_api/federation/migrations/0001_initial.py @@ -8,30 +8,74 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Actor', + name="Actor", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('url', models.URLField(db_index=True, max_length=500, unique=True)), - ('outbox_url', models.URLField(max_length=500)), - ('inbox_url', models.URLField(max_length=500)), - ('following_url', models.URLField(blank=True, max_length=500, null=True)), - ('followers_url', models.URLField(blank=True, max_length=500, null=True)), - ('shared_inbox_url', models.URLField(blank=True, max_length=500, null=True)), - ('type', models.CharField(choices=[('Person', 'Person'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25)), - ('name', models.CharField(blank=True, max_length=200, null=True)), - ('domain', models.CharField(max_length=1000)), - ('summary', models.CharField(blank=True, max_length=500, null=True)), - ('preferred_username', models.CharField(blank=True, max_length=200, null=True)), - ('public_key', models.CharField(blank=True, max_length=5000, null=True)), - ('private_key', models.CharField(blank=True, max_length=5000, null=True)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('last_fetch_date', models.DateTimeField(default=django.utils.timezone.now)), - ('manually_approves_followers', models.NullBooleanField(default=None)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("url", models.URLField(db_index=True, max_length=500, unique=True)), + ("outbox_url", models.URLField(max_length=500)), + ("inbox_url", models.URLField(max_length=500)), + ( + "following_url", + models.URLField(blank=True, max_length=500, null=True), + ), + ( + "followers_url", + models.URLField(blank=True, max_length=500, null=True), + ), + ( + "shared_inbox_url", + models.URLField(blank=True, max_length=500, null=True), + ), + ( + "type", + models.CharField( + choices=[ + ("Person", "Person"), + ("Application", "Application"), + ("Group", "Group"), + ("Organization", "Organization"), + ("Service", "Service"), + ], + default="Person", + max_length=25, + ), + ), + ("name", models.CharField(blank=True, max_length=200, null=True)), + ("domain", models.CharField(max_length=1000)), + ("summary", models.CharField(blank=True, max_length=500, null=True)), + ( + "preferred_username", + models.CharField(blank=True, max_length=200, null=True), + ), + ( + "public_key", + models.CharField(blank=True, max_length=5000, null=True), + ), + ( + "private_key", + models.CharField(blank=True, max_length=5000, null=True), + ), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "last_fetch_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("manually_approves_followers", models.NullBooleanField(default=None)), ], - ), + ) ] diff --git a/api/funkwhale_api/federation/migrations/0002_auto_20180403_1620.py b/api/funkwhale_api/federation/migrations/0002_auto_20180403_1620.py index 2200424d8..9c848ac58 100644 --- a/api/funkwhale_api/federation/migrations/0002_auto_20180403_1620.py +++ b/api/funkwhale_api/federation/migrations/0002_auto_20180403_1620.py @@ -5,13 +5,10 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('federation', '0001_initial'), - ] + dependencies = [("federation", "0001_initial")] operations = [ migrations.AlterUniqueTogether( - name='actor', - unique_together={('domain', 'preferred_username')}, - ), + name="actor", unique_together={("domain", "preferred_username")} + ) ] diff --git a/api/funkwhale_api/federation/migrations/0003_auto_20180407_1010.py b/api/funkwhale_api/federation/migrations/0003_auto_20180407_1010.py index 12e3d73fe..021b2ad1c 100644 --- a/api/funkwhale_api/federation/migrations/0003_auto_20180407_1010.py +++ b/api/funkwhale_api/federation/migrations/0003_auto_20180407_1010.py @@ -10,7 +10,7 @@ import uuid def delete_system_actors(apps, schema_editor): """Revert site domain and name to default.""" Actor = apps.get_model("federation", "Actor") - Actor.objects.filter(preferred_username__in=['test', 'library']).delete() + Actor.objects.filter(preferred_username__in=["test", "library"]).delete() def backward(apps, schema_editor): @@ -19,76 +19,168 @@ def backward(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('federation', '0002_auto_20180403_1620'), - ] + dependencies = [("federation", "0002_auto_20180403_1620")] operations = [ migrations.RunPython(delete_system_actors, backward), migrations.CreateModel( - name='Follow', + name="Follow", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('modification_date', models.DateTimeField(auto_now=True)), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emitted_follows', to='federation.Actor')), - ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_follows', to='federation.Actor')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("modification_date", models.DateTimeField(auto_now=True)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="emitted_follows", + to="federation.Actor", + ), + ), + ( + "target", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="received_follows", + to="federation.Actor", + ), + ), ], ), migrations.CreateModel( - name='FollowRequest', + name="FollowRequest", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('modification_date', models.DateTimeField(auto_now=True)), - ('approved', models.NullBooleanField(default=None)), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emmited_follow_requests', to='federation.Actor')), - ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_follow_requests', to='federation.Actor')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("modification_date", models.DateTimeField(auto_now=True)), + ("approved", models.NullBooleanField(default=None)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="emmited_follow_requests", + to="federation.Actor", + ), + ), + ( + "target", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="received_follow_requests", + to="federation.Actor", + ), + ), ], ), migrations.CreateModel( - name='Library', + name="Library", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('modification_date', models.DateTimeField(auto_now=True)), - ('fetched_date', models.DateTimeField(blank=True, null=True)), - ('uuid', models.UUIDField(default=uuid.uuid4)), - ('url', models.URLField()), - ('federation_enabled', models.BooleanField()), - ('download_files', models.BooleanField()), - ('autoimport', models.BooleanField()), - ('tracks_count', models.PositiveIntegerField(blank=True, null=True)), - ('actor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='library', to='federation.Actor')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("modification_date", models.DateTimeField(auto_now=True)), + ("fetched_date", models.DateTimeField(blank=True, null=True)), + ("uuid", models.UUIDField(default=uuid.uuid4)), + ("url", models.URLField()), + ("federation_enabled", models.BooleanField()), + ("download_files", models.BooleanField()), + ("autoimport", models.BooleanField()), + ("tracks_count", models.PositiveIntegerField(blank=True, null=True)), + ( + "actor", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="library", + to="federation.Actor", + ), + ), ], ), migrations.CreateModel( - name='LibraryTrack', + name="LibraryTrack", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('url', models.URLField(unique=True)), - ('audio_url', models.URLField()), - ('audio_mimetype', models.CharField(max_length=200)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('modification_date', models.DateTimeField(auto_now=True)), - ('fetched_date', models.DateTimeField(blank=True, null=True)), - ('published_date', models.DateTimeField(blank=True, null=True)), - ('artist_name', models.CharField(max_length=500)), - ('album_title', models.CharField(max_length=500)), - ('title', models.CharField(max_length=500)), - ('metadata', django.contrib.postgres.fields.jsonb.JSONField(default={}, max_length=10000)), - ('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracks', to='federation.Library')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("url", models.URLField(unique=True)), + ("audio_url", models.URLField()), + ("audio_mimetype", models.CharField(max_length=200)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("modification_date", models.DateTimeField(auto_now=True)), + ("fetched_date", models.DateTimeField(blank=True, null=True)), + ("published_date", models.DateTimeField(blank=True, null=True)), + ("artist_name", models.CharField(max_length=500)), + ("album_title", models.CharField(max_length=500)), + ("title", models.CharField(max_length=500)), + ( + "metadata", + django.contrib.postgres.fields.jsonb.JSONField( + default={}, max_length=10000 + ), + ), + ( + "library", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tracks", + to="federation.Library", + ), + ), ], ), migrations.AddField( - model_name='actor', - name='followers', - field=models.ManyToManyField(related_name='following', through='federation.Follow', to='federation.Actor'), + model_name="actor", + name="followers", + field=models.ManyToManyField( + related_name="following", + through="federation.Follow", + to="federation.Actor", + ), ), migrations.AlterUniqueTogether( - name='follow', - unique_together={('actor', 'target')}, + name="follow", unique_together={("actor", "target")} ), ] diff --git a/api/funkwhale_api/federation/migrations/0004_auto_20180410_2025.py b/api/funkwhale_api/federation/migrations/0004_auto_20180410_2025.py index bea4d14ae..f0e5cf1d6 100644 --- a/api/funkwhale_api/federation/migrations/0004_auto_20180410_2025.py +++ b/api/funkwhale_api/federation/migrations/0004_auto_20180410_2025.py @@ -6,30 +6,26 @@ import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('federation', '0003_auto_20180407_1010'), - ] + dependencies = [("federation", "0003_auto_20180407_1010")] operations = [ - migrations.RemoveField( - model_name='followrequest', - name='actor', - ), - migrations.RemoveField( - model_name='followrequest', - name='target', - ), + migrations.RemoveField(model_name="followrequest", name="actor"), + migrations.RemoveField(model_name="followrequest", name="target"), migrations.AddField( - model_name='follow', - name='approved', + model_name="follow", + name="approved", field=models.NullBooleanField(default=None), ), migrations.AddField( - model_name='library', - name='follow', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library', to='federation.Follow'), - ), - migrations.DeleteModel( - name='FollowRequest', + model_name="library", + name="follow", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="library", + to="federation.Follow", + ), ), + migrations.DeleteModel(name="FollowRequest"), ] diff --git a/api/funkwhale_api/federation/migrations/0005_auto_20180413_1723.py b/api/funkwhale_api/federation/migrations/0005_auto_20180413_1723.py index 00ba5c83d..0b2029e95 100644 --- a/api/funkwhale_api/federation/migrations/0005_auto_20180413_1723.py +++ b/api/funkwhale_api/federation/migrations/0005_auto_20180413_1723.py @@ -8,19 +8,25 @@ import funkwhale_api.federation.models class Migration(migrations.Migration): - dependencies = [ - ('federation', '0004_auto_20180410_2025'), - ] + dependencies = [("federation", "0004_auto_20180410_2025")] operations = [ migrations.AddField( - model_name='librarytrack', - name='audio_file', - field=models.FileField(blank=True, null=True, upload_to=funkwhale_api.federation.models.get_file_path), + model_name="librarytrack", + name="audio_file", + field=models.FileField( + blank=True, + null=True, + upload_to=funkwhale_api.federation.models.get_file_path, + ), ), migrations.AlterField( - model_name='librarytrack', - name='metadata', - field=django.contrib.postgres.fields.jsonb.JSONField(default={}, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000), + model_name="librarytrack", + name="metadata", + field=django.contrib.postgres.fields.jsonb.JSONField( + default={}, + encoder=django.core.serializers.json.DjangoJSONEncoder, + max_length=10000, + ), ), ] diff --git a/api/funkwhale_api/federation/migrations/0006_auto_20180521_1702.py b/api/funkwhale_api/federation/migrations/0006_auto_20180521_1702.py index 7dcf85670..eb731f0aa 100644 --- a/api/funkwhale_api/federation/migrations/0006_auto_20180521_1702.py +++ b/api/funkwhale_api/federation/migrations/0006_auto_20180521_1702.py @@ -5,24 +5,20 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('federation', '0005_auto_20180413_1723'), - ] + dependencies = [("federation", "0005_auto_20180413_1723")] operations = [ migrations.AlterField( - model_name='library', - name='url', + model_name="library", name="url", field=models.URLField(max_length=500) + ), + migrations.AlterField( + model_name="librarytrack", + name="audio_url", field=models.URLField(max_length=500), ), migrations.AlterField( - model_name='librarytrack', - name='audio_url', - field=models.URLField(max_length=500), - ), - migrations.AlterField( - model_name='librarytrack', - name='url', + model_name="librarytrack", + name="url", field=models.URLField(max_length=500, unique=True), ), ] diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 8b4f28475..979b0674a 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -1,6 +1,6 @@ import os -import uuid import tempfile +import uuid from django.conf import settings from django.contrib.postgres.fields import JSONField @@ -12,16 +12,16 @@ from funkwhale_api.common import session from funkwhale_api.music import utils as music_utils TYPE_CHOICES = [ - ('Person', 'Person'), - ('Application', 'Application'), - ('Group', 'Group'), - ('Organization', 'Organization'), - ('Service', 'Service'), + ("Person", "Person"), + ("Application", "Application"), + ("Group", "Group"), + ("Organization", "Organization"), + ("Service", "Service"), ] class Actor(models.Model): - ap_type = 'Actor' + ap_type = "Actor" url = models.URLField(unique=True, max_length=500, db_index=True) outbox_url = models.URLField(max_length=500) @@ -29,49 +29,41 @@ class Actor(models.Model): following_url = models.URLField(max_length=500, null=True, blank=True) followers_url = models.URLField(max_length=500, null=True, blank=True) shared_inbox_url = models.URLField(max_length=500, null=True, blank=True) - type = models.CharField( - choices=TYPE_CHOICES, default='Person', max_length=25) + type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25) name = models.CharField(max_length=200, null=True, blank=True) domain = models.CharField(max_length=1000) summary = models.CharField(max_length=500, null=True, blank=True) - preferred_username = models.CharField( - max_length=200, null=True, blank=True) + preferred_username = models.CharField(max_length=200, null=True, blank=True) public_key = models.CharField(max_length=5000, null=True, blank=True) private_key = models.CharField(max_length=5000, null=True, blank=True) creation_date = models.DateTimeField(default=timezone.now) - last_fetch_date = models.DateTimeField( - default=timezone.now) + last_fetch_date = models.DateTimeField(default=timezone.now) manually_approves_followers = models.NullBooleanField(default=None) followers = models.ManyToManyField( - to='self', + to="self", symmetrical=False, - through='Follow', - through_fields=('target', 'actor'), - related_name='following', + through="Follow", + through_fields=("target", "actor"), + related_name="following", ) class Meta: - unique_together = ['domain', 'preferred_username'] + unique_together = ["domain", "preferred_username"] @property def webfinger_subject(self): - return '{}@{}'.format( - self.preferred_username, - settings.FEDERATION_HOSTNAME, - ) + return "{}@{}".format(self.preferred_username, settings.FEDERATION_HOSTNAME) @property def private_key_id(self): - return '{}#main-key'.format(self.url) + return "{}#main-key".format(self.url) @property def mention_username(self): - return '@{}@{}'.format(self.preferred_username, self.domain) + return "@{}@{}".format(self.preferred_username, self.domain) def save(self, **kwargs): - lowercase_fields = [ - 'domain', - ] + lowercase_fields = ["domain"] for field in lowercase_fields: v = getattr(self, field, None) if v: @@ -86,58 +78,54 @@ class Actor(models.Model): @property def is_system(self): from . import actors - return all([ - settings.FEDERATION_HOSTNAME == self.domain, - self.preferred_username in actors.SYSTEM_ACTORS - ]) + + return all( + [ + settings.FEDERATION_HOSTNAME == self.domain, + self.preferred_username in actors.SYSTEM_ACTORS, + ] + ) @property def system_conf(self): from . import actors + if self.is_system: return actors.SYSTEM_ACTORS[self.preferred_username] def get_approved_followers(self): follows = self.received_follows.filter(approved=True) - return self.followers.filter( - pk__in=follows.values_list('actor', flat=True)) + return self.followers.filter(pk__in=follows.values_list("actor", flat=True)) class Follow(models.Model): - ap_type = 'Follow' + ap_type = "Follow" uuid = models.UUIDField(default=uuid.uuid4, unique=True) actor = models.ForeignKey( - Actor, - related_name='emitted_follows', - on_delete=models.CASCADE, + Actor, related_name="emitted_follows", on_delete=models.CASCADE ) target = models.ForeignKey( - Actor, - related_name='received_follows', - on_delete=models.CASCADE, + Actor, related_name="received_follows", on_delete=models.CASCADE ) creation_date = models.DateTimeField(default=timezone.now) - modification_date = models.DateTimeField( - auto_now=True) + modification_date = models.DateTimeField(auto_now=True) approved = models.NullBooleanField(default=None) class Meta: - unique_together = ['actor', 'target'] + unique_together = ["actor", "target"] def get_federation_url(self): - return '{}#follows/{}'.format(self.actor.url, self.uuid) + return "{}#follows/{}".format(self.actor.url, self.uuid) class Library(models.Model): creation_date = models.DateTimeField(default=timezone.now) - modification_date = models.DateTimeField( - auto_now=True) + modification_date = models.DateTimeField(auto_now=True) fetched_date = models.DateTimeField(null=True, blank=True) actor = models.OneToOneField( - Actor, - on_delete=models.CASCADE, - related_name='library') + Actor, on_delete=models.CASCADE, related_name="library" + ) uuid = models.UUIDField(default=uuid.uuid4) url = models.URLField(max_length=500) @@ -149,69 +137,60 @@ class Library(models.Model): autoimport = models.BooleanField() tracks_count = models.PositiveIntegerField(null=True, blank=True) follow = models.OneToOneField( - Follow, - related_name='library', - null=True, - blank=True, - on_delete=models.SET_NULL, + Follow, related_name="library", null=True, blank=True, on_delete=models.SET_NULL ) def get_file_path(instance, filename): uid = str(uuid.uuid4()) chunk_size = 2 - chunks = [uid[i:i+chunk_size] for i in range(0, len(uid), chunk_size)] + chunks = [uid[i : i + chunk_size] for i in range(0, len(uid), chunk_size)] parts = chunks[:3] + [filename] - return os.path.join('federation_cache', *parts) + return os.path.join("federation_cache", *parts) class LibraryTrack(models.Model): url = models.URLField(unique=True, max_length=500) audio_url = models.URLField(max_length=500) audio_mimetype = models.CharField(max_length=200) - audio_file = models.FileField( - upload_to=get_file_path, - null=True, - blank=True) + audio_file = models.FileField(upload_to=get_file_path, null=True, blank=True) creation_date = models.DateTimeField(default=timezone.now) - modification_date = models.DateTimeField( - auto_now=True) + modification_date = models.DateTimeField(auto_now=True) fetched_date = models.DateTimeField(null=True, blank=True) published_date = models.DateTimeField(null=True, blank=True) library = models.ForeignKey( - Library, related_name='tracks', on_delete=models.CASCADE) + Library, related_name="tracks", on_delete=models.CASCADE + ) artist_name = models.CharField(max_length=500) album_title = models.CharField(max_length=500) title = models.CharField(max_length=500) - metadata = JSONField( - default={}, max_length=10000, encoder=DjangoJSONEncoder) + metadata = JSONField(default={}, max_length=10000, encoder=DjangoJSONEncoder) @property def mbid(self): try: - return self.metadata['recording']['musicbrainz_id'] + return self.metadata["recording"]["musicbrainz_id"] except KeyError: pass def download_audio(self): from . import actors - auth = actors.SYSTEM_ACTORS['library'].get_request_auth() + + auth = actors.SYSTEM_ACTORS["library"].get_request_auth() remote_response = session.get_session().get( self.audio_url, auth=auth, stream=True, timeout=20, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - headers={ - 'Content-Type': 'application/activity+json' - } + headers={"Content-Type": "application/activity+json"}, ) with remote_response as r: remote_response.raise_for_status() extension = music_utils.get_ext_from_type(self.audio_mimetype) - title = ' - '.join([self.title, self.album_title, self.artist_name]) - filename = '{}.{}'.format(title, extension) + title = " - ".join([self.title, self.album_title, self.artist_name]) + filename = "{}.{}".format(title, extension) tmp_file = tempfile.TemporaryFile() for chunk in r.iter_content(chunk_size=512): tmp_file.write(chunk) diff --git a/api/funkwhale_api/federation/parsers.py b/api/funkwhale_api/federation/parsers.py index 874d808f9..8afe21a23 100644 --- a/api/funkwhale_api/federation/parsers.py +++ b/api/funkwhale_api/federation/parsers.py @@ -2,4 +2,4 @@ from rest_framework import parsers class ActivityParser(parsers.JSONParser): - media_type = 'application/activity+json' + media_type = "application/activity+json" diff --git a/api/funkwhale_api/federation/permissions.py b/api/funkwhale_api/federation/permissions.py index 438b675cb..a08d57e5f 100644 --- a/api/funkwhale_api/federation/permissions.py +++ b/api/funkwhale_api/federation/permissions.py @@ -1,21 +1,19 @@ -from django.conf import settings from rest_framework.permissions import BasePermission from funkwhale_api.common import preferences + from . import actors class LibraryFollower(BasePermission): - def has_permission(self, request, view): - if not preferences.get('federation__music_needs_approval'): + if not preferences.get("federation__music_needs_approval"): return True - actor = getattr(request, 'actor', None) + actor = getattr(request, "actor", None) if actor is None: return False - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() - return library.received_follows.filter( - approved=True, actor=actor).exists() + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() + return library.received_follows.filter(approved=True, actor=actor).exists() diff --git a/api/funkwhale_api/federation/renderers.py b/api/funkwhale_api/federation/renderers.py index 642b63462..d72c4c06a 100644 --- a/api/funkwhale_api/federation/renderers.py +++ b/api/funkwhale_api/federation/renderers.py @@ -2,8 +2,8 @@ from rest_framework.renderers import JSONRenderer class ActivityPubRenderer(JSONRenderer): - media_type = 'application/activity+json' + media_type = "application/activity+json" class WebfingerRenderer(JSONRenderer): - media_type = 'application/jrd+json' + media_type = "application/jrd+json" diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 6ffffaa9a..062f74f47 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -1,27 +1,20 @@ import logging import urllib.parse -from django.urls import reverse -from django.conf import settings from django.core.paginator import Paginator from django.db import transaction - from rest_framework import serializers -from dynamic_preferences.registries import global_preferences_registry -from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.music import models as music_models from funkwhale_api.music import tasks as music_tasks -from . import activity -from . import filters -from . import models -from . import utils +from . import activity, filters, models, utils AP_CONTEXT = [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ] @@ -43,58 +36,58 @@ class ActorSerializer(serializers.Serializer): def to_representation(self, instance): ret = { - 'id': instance.url, - 'outbox': instance.outbox_url, - 'inbox': instance.inbox_url, - 'preferredUsername': instance.preferred_username, - 'type': instance.type, + "id": instance.url, + "outbox": instance.outbox_url, + "inbox": instance.inbox_url, + "preferredUsername": instance.preferred_username, + "type": instance.type, } if instance.name: - ret['name'] = instance.name + ret["name"] = instance.name if instance.followers_url: - ret['followers'] = instance.followers_url + ret["followers"] = instance.followers_url if instance.following_url: - ret['following'] = instance.following_url + ret["following"] = instance.following_url if instance.summary: - ret['summary'] = instance.summary + ret["summary"] = instance.summary if instance.manually_approves_followers is not None: - ret['manuallyApprovesFollowers'] = instance.manually_approves_followers + ret["manuallyApprovesFollowers"] = instance.manually_approves_followers - ret['@context'] = AP_CONTEXT + ret["@context"] = AP_CONTEXT if instance.public_key: - ret['publicKey'] = { - 'owner': instance.url, - 'publicKeyPem': instance.public_key, - 'id': '{}#main-key'.format(instance.url) + ret["publicKey"] = { + "owner": instance.url, + "publicKeyPem": instance.public_key, + "id": "{}#main-key".format(instance.url), } - ret['endpoints'] = {} + ret["endpoints"] = {} if instance.shared_inbox_url: - ret['endpoints']['sharedInbox'] = instance.shared_inbox_url + ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url return ret def prepare_missing_fields(self): kwargs = { - 'url': self.validated_data['id'], - 'outbox_url': self.validated_data['outbox'], - 'inbox_url': self.validated_data['inbox'], - 'following_url': self.validated_data.get('following'), - 'followers_url': self.validated_data.get('followers'), - 'summary': self.validated_data.get('summary'), - 'type': self.validated_data['type'], - 'name': self.validated_data.get('name'), - 'preferred_username': self.validated_data['preferredUsername'], + "url": self.validated_data["id"], + "outbox_url": self.validated_data["outbox"], + "inbox_url": self.validated_data["inbox"], + "following_url": self.validated_data.get("following"), + "followers_url": self.validated_data.get("followers"), + "summary": self.validated_data.get("summary"), + "type": self.validated_data["type"], + "name": self.validated_data.get("name"), + "preferred_username": self.validated_data["preferredUsername"], } - maf = self.validated_data.get('manuallyApprovesFollowers') + maf = self.validated_data.get("manuallyApprovesFollowers") if maf is not None: - kwargs['manually_approves_followers'] = maf - domain = urllib.parse.urlparse(kwargs['url']).netloc - kwargs['domain'] = domain - for endpoint, url in self.initial_data.get('endpoints', {}).items(): - if endpoint == 'sharedInbox': - kwargs['shared_inbox_url'] = url + kwargs["manually_approves_followers"] = maf + domain = urllib.parse.urlparse(kwargs["url"]).netloc + kwargs["domain"] = domain + for endpoint, url in self.initial_data.get("endpoints", {}).items(): + if endpoint == "sharedInbox": + kwargs["shared_inbox_url"] = url break try: - kwargs['public_key'] = self.initial_data['publicKey']['publicKeyPem'] + kwargs["public_key"] = self.initial_data["publicKey"]["publicKeyPem"] except KeyError: pass return kwargs @@ -106,10 +99,7 @@ class ActorSerializer(serializers.Serializer): def save(self, **kwargs): d = self.prepare_missing_fields() d.update(kwargs) - return models.Actor.objects.update_or_create( - url=d['url'], - defaults=d, - )[0] + return models.Actor.objects.update_or_create(url=d["url"], defaults=d)[0] def validate_summary(self, value): if value: @@ -120,35 +110,33 @@ class APIActorSerializer(serializers.ModelSerializer): class Meta: model = models.Actor fields = [ - 'id', - 'url', - 'creation_date', - 'summary', - 'preferred_username', - 'name', - 'last_fetch_date', - 'domain', - 'type', - 'manually_approves_followers', - + "id", + "url", + "creation_date", + "summary", + "preferred_username", + "name", + "last_fetch_date", + "domain", + "type", + "manually_approves_followers", ] class LibraryActorSerializer(ActorSerializer): - url = serializers.ListField( - child=serializers.JSONField()) + url = serializers.ListField(child=serializers.JSONField()) def validate(self, validated_data): try: - urls = validated_data['url'] + urls = validated_data["url"] except KeyError: - raise serializers.ValidationError('Missing URL field') + raise serializers.ValidationError("Missing URL field") for u in urls: try: - if u['name'] != 'library': + if u["name"] != "library": continue - validated_data['library_url'] = u['href'] + validated_data["library_url"] = u["href"] break except KeyError: continue @@ -160,12 +148,12 @@ class APIFollowSerializer(serializers.ModelSerializer): class Meta: model = models.Follow fields = [ - 'uuid', - 'actor', - 'target', - 'approved', - 'creation_date', - 'modification_date', + "uuid", + "actor", + "target", + "approved", + "creation_date", + "modification_date", ] @@ -177,19 +165,19 @@ class APILibrarySerializer(serializers.ModelSerializer): model = models.Library read_only_fields = [ - 'actor', - 'uuid', - 'url', - 'tracks_count', - 'follow', - 'fetched_date', - 'modification_date', - 'creation_date', + "actor", + "uuid", + "url", + "tracks_count", + "follow", + "fetched_date", + "modification_date", + "creation_date", ] fields = [ - 'autoimport', - 'federation_enabled', - 'download_files', + "autoimport", + "federation_enabled", + "download_files", ] + read_only_fields @@ -203,24 +191,22 @@ class APILibraryFollowUpdateSerializer(serializers.Serializer): def validate_follow(self, value): from . import actors - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - qs = models.Follow.objects.filter( - pk=value, - target=library_actor, - ) + + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + qs = models.Follow.objects.filter(pk=value, target=library_actor) try: return qs.get() except models.Follow.DoesNotExist: - raise serializers.ValidationError('Invalid follow') + raise serializers.ValidationError("Invalid follow") def save(self): - new_status = self.validated_data['approved'] - follow = self.validated_data['follow'] + new_status = self.validated_data["approved"] + follow = self.validated_data["follow"] if new_status == follow.approved: return follow follow.approved = new_status - follow.save(update_fields=['approved', 'modification_date']) + follow.save(update_fields=["approved", "modification_date"]) if new_status: activity.accept_follow(follow) return follow @@ -233,19 +219,13 @@ class APILibraryCreateSerializer(serializers.ModelSerializer): class Meta: model = models.Library - fields = [ - 'uuid', - 'actor', - 'autoimport', - 'federation_enabled', - 'download_files', - ] + fields = ["uuid", "actor", "autoimport", "federation_enabled", "download_files"] def validate(self, validated_data): from . import actors from . import library - actor_url = validated_data['actor'] + actor_url = validated_data["actor"] actor_data = actors.get_actor_data(actor_url) acs = LibraryActorSerializer(data=actor_data) acs.is_valid(raise_exception=True) @@ -253,43 +233,39 @@ class APILibraryCreateSerializer(serializers.ModelSerializer): actor = models.Actor.objects.get(url=actor_url) except models.Actor.DoesNotExist: actor = acs.save() - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - validated_data['follow'] = models.Follow.objects.get_or_create( - actor=library_actor, - target=actor, + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + validated_data["follow"] = models.Follow.objects.get_or_create( + actor=library_actor, target=actor )[0] - if validated_data['follow'].approved is None: + if validated_data["follow"].approved is None: funkwhale_utils.on_commit( activity.deliver, - FollowSerializer(validated_data['follow']).data, - on_behalf_of=validated_data['follow'].actor, - to=[validated_data['follow'].target.url], + FollowSerializer(validated_data["follow"]).data, + on_behalf_of=validated_data["follow"].actor, + to=[validated_data["follow"].target.url], ) - library_data = library.get_library_data( - acs.validated_data['library_url']) - if 'errors' in library_data: + library_data = library.get_library_data(acs.validated_data["library_url"]) + if "errors" in library_data: # we pass silently because it may means we require permission # before scanning pass - validated_data['library'] = library_data - validated_data['library'].setdefault( - 'id', acs.validated_data['library_url'] - ) - validated_data['actor'] = actor + validated_data["library"] = library_data + validated_data["library"].setdefault("id", acs.validated_data["library_url"]) + validated_data["actor"] = actor return validated_data def create(self, validated_data): library = models.Library.objects.update_or_create( - url=validated_data['library']['id'], + url=validated_data["library"]["id"], defaults={ - 'actor': validated_data['actor'], - 'follow': validated_data['follow'], - 'tracks_count': validated_data['library'].get('totalItems'), - 'federation_enabled': validated_data['federation_enabled'], - 'autoimport': validated_data['autoimport'], - 'download_files': validated_data['download_files'], - } + "actor": validated_data["actor"], + "follow": validated_data["follow"], + "tracks_count": validated_data["library"].get("totalItems"), + "federation_enabled": validated_data["federation_enabled"], + "autoimport": validated_data["autoimport"], + "download_files": validated_data["download_files"], + }, )[0] return library @@ -301,75 +277,74 @@ class APILibraryTrackSerializer(serializers.ModelSerializer): class Meta: model = models.LibraryTrack fields = [ - 'id', - 'url', - 'audio_url', - 'audio_mimetype', - 'creation_date', - 'modification_date', - 'fetched_date', - 'published_date', - 'metadata', - 'artist_name', - 'album_title', - 'title', - 'library', - 'local_track_file', - 'status', + "id", + "url", + "audio_url", + "audio_mimetype", + "creation_date", + "modification_date", + "fetched_date", + "published_date", + "metadata", + "artist_name", + "album_title", + "title", + "library", + "local_track_file", + "status", ] def get_status(self, o): try: if o.local_track_file is not None: - return 'imported' + return "imported" except music_models.TrackFile.DoesNotExist: pass for job in o.import_jobs.all(): - if job.status == 'pending': - return 'import_pending' - return 'not_imported' + if job.status == "pending": + return "import_pending" + return "not_imported" class FollowSerializer(serializers.Serializer): id = serializers.URLField(max_length=500) object = serializers.URLField(max_length=500) actor = serializers.URLField(max_length=500) - type = serializers.ChoiceField(choices=['Follow']) + type = serializers.ChoiceField(choices=["Follow"]) def validate_object(self, v): - expected = self.context.get('follow_target') + expected = self.context.get("follow_target") if expected and expected.url != v: - raise serializers.ValidationError('Invalid target') + raise serializers.ValidationError("Invalid target") try: return models.Actor.objects.get(url=v) except models.Actor.DoesNotExist: - raise serializers.ValidationError('Target not found') + raise serializers.ValidationError("Target not found") def validate_actor(self, v): - expected = self.context.get('follow_actor') + expected = self.context.get("follow_actor") if expected and expected.url != v: - raise serializers.ValidationError('Invalid actor') + raise serializers.ValidationError("Invalid actor") try: return models.Actor.objects.get(url=v) except models.Actor.DoesNotExist: - raise serializers.ValidationError('Actor not found') + raise serializers.ValidationError("Actor not found") def save(self, **kwargs): return models.Follow.objects.get_or_create( - actor=self.validated_data['actor'], - target=self.validated_data['object'], - **kwargs, + actor=self.validated_data["actor"], + target=self.validated_data["object"], + **kwargs, # noqa )[0] def to_representation(self, instance): return { - '@context': AP_CONTEXT, - 'actor': instance.actor.url, - 'id': instance.get_federation_url(), - 'object': instance.target.url, - 'type': 'Follow' + "@context": AP_CONTEXT, + "actor": instance.actor.url, + "id": instance.get_federation_url(), + "object": instance.target.url, + "type": "Follow", } - return ret class APIFollowSerializer(serializers.ModelSerializer): @@ -379,13 +354,13 @@ class APIFollowSerializer(serializers.ModelSerializer): class Meta: model = models.Follow fields = [ - 'uuid', - 'id', - 'approved', - 'creation_date', - 'modification_date', - 'actor', - 'target', + "uuid", + "id", + "approved", + "creation_date", + "modification_date", + "actor", + "target", ] @@ -393,84 +368,87 @@ class AcceptFollowSerializer(serializers.Serializer): id = serializers.URLField(max_length=500) actor = serializers.URLField(max_length=500) object = FollowSerializer() - type = serializers.ChoiceField(choices=['Accept']) + type = serializers.ChoiceField(choices=["Accept"]) def validate_actor(self, v): - expected = self.context.get('follow_target') + expected = self.context.get("follow_target") if expected and expected.url != v: - raise serializers.ValidationError('Invalid actor') + raise serializers.ValidationError("Invalid actor") try: return models.Actor.objects.get(url=v) except models.Actor.DoesNotExist: - raise serializers.ValidationError('Actor not found') + raise serializers.ValidationError("Actor not found") def validate(self, validated_data): # we ensure the accept actor actually match the follow target - if validated_data['actor'] != validated_data['object']['object']: - raise serializers.ValidationError('Actor mismatch') + if validated_data["actor"] != validated_data["object"]["object"]: + raise serializers.ValidationError("Actor mismatch") try: - validated_data['follow'] = models.Follow.objects.filter( - target=validated_data['actor'], - actor=validated_data['object']['actor'] - ).exclude(approved=True).get() + validated_data["follow"] = ( + models.Follow.objects.filter( + target=validated_data["actor"], + actor=validated_data["object"]["actor"], + ) + .exclude(approved=True) + .get() + ) except models.Follow.DoesNotExist: - raise serializers.ValidationError('No follow to accept') + raise serializers.ValidationError("No follow to accept") return validated_data def to_representation(self, instance): return { "@context": AP_CONTEXT, - "id": instance.get_federation_url() + '/accept', + "id": instance.get_federation_url() + "/accept", "type": "Accept", "actor": instance.target.url, - "object": FollowSerializer(instance).data + "object": FollowSerializer(instance).data, } def save(self): - self.validated_data['follow'].approved = True - self.validated_data['follow'].save() - return self.validated_data['follow'] + self.validated_data["follow"].approved = True + self.validated_data["follow"].save() + return self.validated_data["follow"] class UndoFollowSerializer(serializers.Serializer): id = serializers.URLField(max_length=500) actor = serializers.URLField(max_length=500) object = FollowSerializer() - type = serializers.ChoiceField(choices=['Undo']) + type = serializers.ChoiceField(choices=["Undo"]) def validate_actor(self, v): - expected = self.context.get('follow_target') + expected = self.context.get("follow_target") if expected and expected.url != v: - raise serializers.ValidationError('Invalid actor') + raise serializers.ValidationError("Invalid actor") try: return models.Actor.objects.get(url=v) except models.Actor.DoesNotExist: - raise serializers.ValidationError('Actor not found') + raise serializers.ValidationError("Actor not found") def validate(self, validated_data): # we ensure the accept actor actually match the follow actor - if validated_data['actor'] != validated_data['object']['actor']: - raise serializers.ValidationError('Actor mismatch') + if validated_data["actor"] != validated_data["object"]["actor"]: + raise serializers.ValidationError("Actor mismatch") try: - validated_data['follow'] = models.Follow.objects.filter( - actor=validated_data['actor'], - target=validated_data['object']['object'] + validated_data["follow"] = models.Follow.objects.filter( + actor=validated_data["actor"], target=validated_data["object"]["object"] ).get() except models.Follow.DoesNotExist: - raise serializers.ValidationError('No follow to remove') + raise serializers.ValidationError("No follow to remove") return validated_data def to_representation(self, instance): return { "@context": AP_CONTEXT, - "id": instance.get_federation_url() + '/undo', + "id": instance.get_federation_url() + "/undo", "type": "Undo", "actor": instance.actor.url, - "object": FollowSerializer(instance).data + "object": FollowSerializer(instance).data, } def save(self): - return self.validated_data['follow'].delete() + return self.validated_data["follow"].delete() class ActorWebfingerSerializer(serializers.Serializer): @@ -480,68 +458,59 @@ class ActorWebfingerSerializer(serializers.Serializer): actor_url = serializers.URLField(max_length=500, required=False) def validate(self, validated_data): - validated_data['actor_url'] = None - for l in validated_data['links']: + validated_data["actor_url"] = None + for l in validated_data["links"]: try: - if not l['rel'] == 'self': + if not l["rel"] == "self": continue - if not l['type'] == 'application/activity+json': + if not l["type"] == "application/activity+json": continue - validated_data['actor_url'] = l['href'] + validated_data["actor_url"] = l["href"] break except KeyError: pass - if validated_data['actor_url'] is None: - raise serializers.ValidationError('No valid actor url found') + if validated_data["actor_url"] is None: + raise serializers.ValidationError("No valid actor url found") return validated_data def to_representation(self, instance): data = {} - data['subject'] = 'acct:{}'.format(instance.webfinger_subject) - data['links'] = [ - { - 'rel': 'self', - 'href': instance.url, - 'type': 'application/activity+json' - } - ] - data['aliases'] = [ - instance.url + data["subject"] = "acct:{}".format(instance.webfinger_subject) + data["links"] = [ + {"rel": "self", "href": instance.url, "type": "application/activity+json"} ] + data["aliases"] = [instance.url] return data class ActivitySerializer(serializers.Serializer): actor = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500, required=False) - type = serializers.ChoiceField( - choices=[(c, c) for c in activity.ACTIVITY_TYPES]) + type = serializers.ChoiceField(choices=[(c, c) for c in activity.ACTIVITY_TYPES]) object = serializers.JSONField() def validate_object(self, value): try: - type = value['type'] + type = value["type"] except KeyError: - raise serializers.ValidationError('Missing object type') + raise serializers.ValidationError("Missing object type") except TypeError: # probably a URL return value try: object_serializer = OBJECT_SERIALIZERS[type] except KeyError: - raise serializers.ValidationError( - 'Unsupported type {}'.format(type)) + raise serializers.ValidationError("Unsupported type {}".format(type)) serializer = object_serializer(data=value) serializer.is_valid(raise_exception=True) return serializer.data def validate_actor(self, value): - request_actor = self.context.get('actor') + request_actor = self.context.get("actor") if request_actor and request_actor.url != value: raise serializers.ValidationError( - 'The actor making the request do not match' - ' the activity actor' + "The actor making the request do not match" " the activity actor" ) return value @@ -549,47 +518,39 @@ class ActivitySerializer(serializers.Serializer): d = {} d.update(conf) - if self.context.get('include_ap_context', True): - d['@context'] = AP_CONTEXT + if self.context.get("include_ap_context", True): + d["@context"] = AP_CONTEXT return d class ObjectSerializer(serializers.Serializer): id = serializers.URLField(max_length=500) url = serializers.URLField(max_length=500, required=False, allow_null=True) - type = serializers.ChoiceField( - choices=[(c, c) for c in activity.OBJECT_TYPES]) - content = serializers.CharField( - required=False, allow_null=True) - summary = serializers.CharField( - required=False, allow_null=True) - name = serializers.CharField( - required=False, allow_null=True) - published = serializers.DateTimeField( - required=False, allow_null=True) - updated = serializers.DateTimeField( - required=False, allow_null=True) + type = serializers.ChoiceField(choices=[(c, c) for c in activity.OBJECT_TYPES]) + content = serializers.CharField(required=False, allow_null=True) + summary = serializers.CharField(required=False, allow_null=True) + name = serializers.CharField(required=False, allow_null=True) + published = serializers.DateTimeField(required=False, allow_null=True) + updated = serializers.DateTimeField(required=False, allow_null=True) to = serializers.ListField( - child=serializers.URLField(max_length=500), - required=False, allow_null=True) + child=serializers.URLField(max_length=500), required=False, allow_null=True + ) cc = serializers.ListField( - child=serializers.URLField(max_length=500), - required=False, allow_null=True) + child=serializers.URLField(max_length=500), required=False, allow_null=True + ) bto = serializers.ListField( - child=serializers.URLField(max_length=500), - required=False, allow_null=True) + child=serializers.URLField(max_length=500), required=False, allow_null=True + ) bcc = serializers.ListField( - child=serializers.URLField(max_length=500), - required=False, allow_null=True) + child=serializers.URLField(max_length=500), required=False, allow_null=True + ) -OBJECT_SERIALIZERS = { - t: ObjectSerializer - for t in activity.OBJECT_TYPES -} + +OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES} class PaginatedCollectionSerializer(serializers.Serializer): - type = serializers.ChoiceField(choices=['Collection']) + type = serializers.ChoiceField(choices=["Collection"]) totalItems = serializers.IntegerField(min_value=0) actor = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500) @@ -597,30 +558,26 @@ class PaginatedCollectionSerializer(serializers.Serializer): last = serializers.URLField(max_length=500) def to_representation(self, conf): - paginator = Paginator( - conf['items'], - conf.get('page_size', 20) - ) - first = funkwhale_utils.set_query_parameter(conf['id'], page=1) + paginator = Paginator(conf["items"], conf.get("page_size", 20)) + first = funkwhale_utils.set_query_parameter(conf["id"], page=1) current = first - last = funkwhale_utils.set_query_parameter( - conf['id'], page=paginator.num_pages) + last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages) d = { - 'id': conf['id'], - 'actor': conf['actor'].url, - 'totalItems': paginator.count, - 'type': 'Collection', - 'current': current, - 'first': first, - 'last': last, + "id": conf["id"], + "actor": conf["actor"].url, + "totalItems": paginator.count, + "type": "Collection", + "current": current, + "first": first, + "last": last, } - if self.context.get('include_ap_context', True): - d['@context'] = AP_CONTEXT + if self.context.get("include_ap_context", True): + d["@context"] = AP_CONTEXT return d class CollectionPageSerializer(serializers.Serializer): - type = serializers.ChoiceField(choices=['CollectionPage']) + type = serializers.ChoiceField(choices=["CollectionPage"]) totalItems = serializers.IntegerField(min_value=0) items = serializers.ListField() actor = serializers.URLField(max_length=500) @@ -632,7 +589,7 @@ class CollectionPageSerializer(serializers.Serializer): partOf = serializers.URLField(max_length=500) def validate_items(self, v): - item_serializer = self.context.get('item_serializer') + item_serializer = self.context.get("item_serializer") if not item_serializer: return v raw_items = [item_serializer(data=i, context=self.context) for i in v] @@ -641,47 +598,45 @@ class CollectionPageSerializer(serializers.Serializer): if i.is_valid(): valid_items.append(i) else: - logger.debug('Invalid item %s: %s', i.data, i.errors) + logger.debug("Invalid item %s: %s", i.data, i.errors) return valid_items def to_representation(self, conf): - page = conf['page'] - first = funkwhale_utils.set_query_parameter( - conf['id'], page=1) + page = conf["page"] + first = funkwhale_utils.set_query_parameter(conf["id"], page=1) last = funkwhale_utils.set_query_parameter( - conf['id'], page=page.paginator.num_pages) - id = funkwhale_utils.set_query_parameter( - conf['id'], page=page.number) + conf["id"], page=page.paginator.num_pages + ) + id = funkwhale_utils.set_query_parameter(conf["id"], page=page.number) d = { - 'id': id, - 'partOf': conf['id'], - 'actor': conf['actor'].url, - 'totalItems': page.paginator.count, - 'type': 'CollectionPage', - 'first': first, - 'last': last, - 'items': [ - conf['item_serializer']( - i, - context={ - 'actor': conf['actor'], - 'include_ap_context': False} + "id": id, + "partOf": conf["id"], + "actor": conf["actor"].url, + "totalItems": page.paginator.count, + "type": "CollectionPage", + "first": first, + "last": last, + "items": [ + conf["item_serializer"]( + i, context={"actor": conf["actor"], "include_ap_context": False} ).data for i in page.object_list - ] + ], } if page.has_previous(): - d['prev'] = funkwhale_utils.set_query_parameter( - conf['id'], page=page.previous_page_number()) + d["prev"] = funkwhale_utils.set_query_parameter( + conf["id"], page=page.previous_page_number() + ) if page.has_next(): - d['next'] = funkwhale_utils.set_query_parameter( - conf['id'], page=page.next_page_number()) + d["next"] = funkwhale_utils.set_query_parameter( + conf["id"], page=page.next_page_number() + ) - if self.context.get('include_ap_context', True): - d['@context'] = AP_CONTEXT + if self.context.get("include_ap_context", True): + d["@context"] = AP_CONTEXT return d @@ -704,12 +659,9 @@ class AudioMetadataSerializer(serializers.Serializer): artist = ArtistMetadataSerializer() release = ReleaseMetadataSerializer() recording = RecordingMetadataSerializer() - bitrate = serializers.IntegerField( - required=False, allow_null=True, min_value=0) - size = serializers.IntegerField( - required=False, allow_null=True, min_value=0) - length = serializers.IntegerField( - required=False, allow_null=True, min_value=0) + bitrate = serializers.IntegerField(required=False, allow_null=True, min_value=0) + size = serializers.IntegerField(required=False, allow_null=True, min_value=0) + length = serializers.IntegerField(required=False, allow_null=True, min_value=0) class AudioSerializer(serializers.Serializer): @@ -721,41 +673,39 @@ class AudioSerializer(serializers.Serializer): metadata = AudioMetadataSerializer() def validate_type(self, v): - if v != 'Audio': - raise serializers.ValidationError('Invalid type for audio') + if v != "Audio": + raise serializers.ValidationError("Invalid type for audio") return v def validate_url(self, v): try: - url = v['href'] + v["href"] except (KeyError, TypeError): - raise serializers.ValidationError('Missing href') + raise serializers.ValidationError("Missing href") try: - media_type = v['mediaType'] + media_type = v["mediaType"] except (KeyError, TypeError): - raise serializers.ValidationError('Missing mediaType') + raise serializers.ValidationError("Missing mediaType") - if not media_type or not media_type.startswith('audio/'): - raise serializers.ValidationError('Invalid mediaType') + if not media_type or not media_type.startswith("audio/"): + raise serializers.ValidationError("Invalid mediaType") return v def create(self, validated_data): defaults = { - 'audio_mimetype': validated_data['url']['mediaType'], - 'audio_url': validated_data['url']['href'], - 'metadata': validated_data['metadata'], - 'artist_name': validated_data['metadata']['artist']['name'], - 'album_title': validated_data['metadata']['release']['title'], - 'title': validated_data['metadata']['recording']['title'], - 'published_date': validated_data['published'], - 'modification_date': validated_data.get('updated'), + "audio_mimetype": validated_data["url"]["mediaType"], + "audio_url": validated_data["url"]["href"], + "metadata": validated_data["metadata"], + "artist_name": validated_data["metadata"]["artist"]["name"], + "album_title": validated_data["metadata"]["release"]["title"], + "title": validated_data["metadata"]["recording"]["title"], + "published_date": validated_data["published"], + "modification_date": validated_data.get("updated"), } return models.LibraryTrack.objects.get_or_create( - library=self.context['library'], - url=validated_data['id'], - defaults=defaults + library=self.context["library"], url=validated_data["id"], defaults=defaults )[0] def to_representation(self, instance): @@ -764,87 +714,77 @@ class AudioSerializer(serializers.Serializer): artist = instance.track.artist d = { - 'type': 'Audio', - 'id': instance.get_federation_url(), - 'name': instance.track.full_name, - 'published': instance.creation_date.isoformat(), - 'updated': instance.modification_date.isoformat(), - 'metadata': { - 'artist': { - 'musicbrainz_id': str(artist.mbid) if artist.mbid else None, - 'name': artist.name, + "type": "Audio", + "id": instance.get_federation_url(), + "name": instance.track.full_name, + "published": instance.creation_date.isoformat(), + "updated": instance.modification_date.isoformat(), + "metadata": { + "artist": { + "musicbrainz_id": str(artist.mbid) if artist.mbid else None, + "name": artist.name, }, - 'release': { - 'musicbrainz_id': str(album.mbid) if album.mbid else None, - 'title': album.title, + "release": { + "musicbrainz_id": str(album.mbid) if album.mbid else None, + "title": album.title, }, - 'recording': { - 'musicbrainz_id': str(track.mbid) if track.mbid else None, - 'title': track.title, + "recording": { + "musicbrainz_id": str(track.mbid) if track.mbid else None, + "title": track.title, }, - 'bitrate': instance.bitrate, - 'size': instance.size, - 'length': instance.duration, + "bitrate": instance.bitrate, + "size": instance.size, + "length": instance.duration, }, - 'url': { - 'href': utils.full_url(instance.path), - 'type': 'Link', - 'mediaType': instance.mimetype + "url": { + "href": utils.full_url(instance.path), + "type": "Link", + "mediaType": instance.mimetype, }, - 'attributedTo': [ - self.context['actor'].url - ] + "attributedTo": [self.context["actor"].url], } - if self.context.get('include_ap_context', True): - d['@context'] = AP_CONTEXT + if self.context.get("include_ap_context", True): + d["@context"] = AP_CONTEXT return d class CollectionSerializer(serializers.Serializer): - def to_representation(self, conf): d = { - 'id': conf['id'], - 'actor': conf['actor'].url, - 'totalItems': len(conf['items']), - 'type': 'Collection', - 'items': [ - conf['item_serializer']( - i, - context={ - 'actor': conf['actor'], - 'include_ap_context': False} + "id": conf["id"], + "actor": conf["actor"].url, + "totalItems": len(conf["items"]), + "type": "Collection", + "items": [ + conf["item_serializer"]( + i, context={"actor": conf["actor"], "include_ap_context": False} ).data - for i in conf['items'] - ] + for i in conf["items"] + ], } - if self.context.get('include_ap_context', True): - d['@context'] = AP_CONTEXT + if self.context.get("include_ap_context", True): + d["@context"] = AP_CONTEXT return d class LibraryTrackActionSerializer(common_serializers.ActionSerializer): - actions = ['import'] + actions = ["import"] filterset_class = filters.LibraryTrackFilter @transaction.atomic def handle_import(self, objects): batch = music_models.ImportBatch.objects.create( - source='federation', - submitted_by=self.context['submitted_by'] + source="federation", submitted_by=self.context["submitted_by"] ) jobs = [] for lt in objects: job = music_models.ImportJob( - batch=batch, - library_track=lt, - mbid=lt.mbid, - source=lt.url, + batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url ) jobs.append(job) music_models.ImportJob.objects.bulk_create(jobs) music_tasks.import_batch_run.delay(import_batch_id=batch.pk) - return {'batch': {'id': batch.pk}} + return {"batch": {"id": batch.pk}} diff --git a/api/funkwhale_api/federation/signing.py b/api/funkwhale_api/federation/signing.py index 8d984d3ff..15525b3e5 100644 --- a/api/funkwhale_api/federation/signing.py +++ b/api/funkwhale_api/federation/signing.py @@ -1,18 +1,16 @@ import logging + import requests import requests_http_signature -from . import exceptions -from . import utils +from . import exceptions, utils logger = logging.getLogger(__name__) def verify(request, public_key): return requests_http_signature.HTTPSignatureAuth.verify( - request, - key_resolver=lambda **kwargs: public_key, - use_auth_header=False, + request, key_resolver=lambda **kwargs: public_key, use_auth_header=False ) @@ -27,44 +25,37 @@ def verify_django(django_request, public_key): # with requests_http_signature headers[h.lower()] = v try: - signature = headers['Signature'] + signature = headers["Signature"] except KeyError: raise exceptions.MissingSignature - url = 'http://noop{}'.format(django_request.path) - query = django_request.META['QUERY_STRING'] + url = "http://noop{}".format(django_request.path) + query = django_request.META["QUERY_STRING"] if query: - url += '?{}'.format(query) + url += "?{}".format(query) signature_headers = signature.split('headers="')[1].split('",')[0] - expected = signature_headers.split(' ') - logger.debug('Signature expected headers: %s', expected) + expected = signature_headers.split(" ") + logger.debug("Signature expected headers: %s", expected) for header in expected: try: headers[header] except KeyError: - logger.debug('Missing header: %s', header) + logger.debug("Missing header: %s", header) request = requests.Request( - method=django_request.method, - url=url, - data=django_request.body, - headers=headers) + method=django_request.method, url=url, data=django_request.body, headers=headers + ) for h in request.headers.keys(): v = request.headers[h] if v: request.headers[h] = str(v) - prepared_request = request.prepare() + request.prepare() return verify(request, public_key) def get_auth(private_key, private_key_id): return requests_http_signature.HTTPSignatureAuth( use_auth_header=False, - headers=[ - '(request-target)', - 'user-agent', - 'host', - 'date', - 'content-type'], - algorithm='rsa-sha256', - key=private_key.encode('utf-8'), + headers=["(request-target)", "user-agent", "host", "date", "content-type"], + algorithm="rsa-sha256", + key=private_key.encode("utf-8"), key_id=private_key_id, ) diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index 8f931b0ed..d1b5b7bd2 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -6,114 +6,114 @@ import os from django.conf import settings from django.db.models import Q from django.utils import timezone - -from requests.exceptions import RequestException from dynamic_preferences.registries import global_preferences_registry +from requests.exceptions import RequestException from funkwhale_api.common import session -from funkwhale_api.history.models import Listening from funkwhale_api.taskapp import celery from . import actors from . import library as lb -from . import models -from . import signing - +from . import models, signing logger = logging.getLogger(__name__) @celery.app.task( - name='federation.send', + name="federation.send", autoretry_for=[RequestException], retry_backoff=30, - max_retries=5) -@celery.require_instance(models.Actor, 'actor') + max_retries=5, +) +@celery.require_instance(models.Actor, "actor") def send(activity, actor, to): - logger.info('Preparing activity delivery to %s', to) - auth = signing.get_auth( - actor.private_key, actor.private_key_id) + logger.info("Preparing activity delivery to %s", to) + auth = signing.get_auth(actor.private_key, actor.private_key_id) for url in to: recipient_actor = actors.get_actor(url) - logger.debug('delivering to %s', recipient_actor.inbox_url) - logger.debug('activity content: %s', json.dumps(activity)) + logger.debug("delivering to %s", recipient_actor.inbox_url) + logger.debug("activity content: %s", json.dumps(activity)) response = session.get_session().post( auth=auth, json=activity, url=recipient_actor.inbox_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - headers={ - 'Content-Type': 'application/activity+json' - } + headers={"Content-Type": "application/activity+json"}, ) response.raise_for_status() - logger.debug('Remote answered with %s', response.status_code) + logger.debug("Remote answered with %s", response.status_code) @celery.app.task( - name='federation.scan_library', + name="federation.scan_library", autoretry_for=[RequestException], retry_backoff=30, - max_retries=5) -@celery.require_instance(models.Library, 'library') + max_retries=5, +) +@celery.require_instance(models.Library, "library") def scan_library(library, until=None): if not library.federation_enabled: return data = lb.get_library_data(library.url) - scan_library_page.delay( - library_id=library.id, page_url=data['first'], until=until) + scan_library_page.delay(library_id=library.id, page_url=data["first"], until=until) library.fetched_date = timezone.now() - library.tracks_count = data['totalItems'] - library.save(update_fields=['fetched_date', 'tracks_count']) + library.tracks_count = data["totalItems"] + library.save(update_fields=["fetched_date", "tracks_count"]) @celery.app.task( - name='federation.scan_library_page', + name="federation.scan_library_page", autoretry_for=[RequestException], retry_backoff=30, - max_retries=5) -@celery.require_instance(models.Library, 'library') + max_retries=5, +) +@celery.require_instance(models.Library, "library") def scan_library_page(library, page_url, until=None): if not library.federation_enabled: return data = lb.get_library_page(library, page_url) lts = [] - for item_serializer in data['items']: - item_date = item_serializer.validated_data['published'] + for item_serializer in data["items"]: + item_date = item_serializer.validated_data["published"] if until and item_date < until: return lts.append(item_serializer.save()) - next_page = data.get('next') + next_page = data.get("next") if next_page and next_page != page_url: scan_library_page.delay(library_id=library.id, page_url=next_page) -@celery.app.task(name='federation.clean_music_cache') +@celery.app.task(name="federation.clean_music_cache") def clean_music_cache(): preferences = global_preferences_registry.manager() - delay = preferences['federation__music_cache_duration'] + delay = preferences["federation__music_cache_duration"] if delay < 1: return # cache clearing disabled limit = timezone.now() - datetime.timedelta(minutes=delay) - candidates = models.LibraryTrack.objects.filter( - Q(audio_file__isnull=False) & ( - Q(local_track_file__accessed_date__lt=limit) | - Q(local_track_file__accessed_date=None) + candidates = ( + models.LibraryTrack.objects.filter( + Q(audio_file__isnull=False) + & ( + Q(local_track_file__accessed_date__lt=limit) + | Q(local_track_file__accessed_date=None) + ) ) - ).exclude(audio_file='').only('audio_file', 'id') + .exclude(audio_file="") + .only("audio_file", "id") + ) for lt in candidates: lt.audio_file.delete() # we also delete orphaned files, if any - storage = models.LibraryTrack._meta.get_field('audio_file').storage - files = get_files(storage, 'federation_cache') + storage = models.LibraryTrack._meta.get_field("audio_file").storage + files = get_files(storage, "federation_cache") existing = models.LibraryTrack.objects.filter(audio_file__in=files) - missing = set(files) - set(existing.values_list('audio_file', flat=True)) + missing = set(files) - set(existing.values_list("audio_file", flat=True)) for m in missing: storage.delete(m) @@ -124,12 +124,9 @@ def get_files(storage, *parts): in a given directory using django's storage. """ if not parts: - raise ValueError('Missing path') + raise ValueError("Missing path") dirs, files = storage.listdir(os.path.join(*parts)) for dir in dirs: files += get_files(storage, *(list(parts) + [dir])) - return [ - os.path.join(parts[-1], path) - for path in files - ] + return [os.path.join(parts[-1], path) for path in files] diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py index 2c24b5257..2594f5549 100644 --- a/api/funkwhale_api/federation/urls.py +++ b/api/funkwhale_api/federation/urls.py @@ -1,24 +1,16 @@ from django.conf.urls import include, url - from rest_framework import routers + from . import views router = routers.SimpleRouter(trailing_slash=False) music_router = routers.SimpleRouter(trailing_slash=False) router.register( - r'federation/instance/actors', - views.InstanceActorViewSet, - 'instance-actors') -router.register( - r'.well-known', - views.WellKnownViewSet, - 'well-known') - -music_router.register( - r'files', - views.MusicFilesViewSet, - 'files', + r"federation/instance/actors", views.InstanceActorViewSet, "instance-actors" ) +router.register(r".well-known", views.WellKnownViewSet, "well-known") + +music_router.register(r"files", views.MusicFilesViewSet, "files") urlpatterns = router.urls + [ - url('federation/music/', include((music_router.urls, 'music'), namespace='music')) + url("federation/music/", include((music_router.urls, "music"), namespace="music")) ] diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index df093add8..e09870223 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -6,10 +6,10 @@ def full_url(path): Given a relative path, return a full url usable for federation purpose """ root = settings.FUNKWHALE_URL - if path.startswith('/') and root.endswith('/'): + if path.startswith("/") and root.endswith("/"): return root + path[1:] - elif not path.startswith('/') and not root.endswith('/'): - return root + '/' + path + elif not path.startswith("/") and not root.endswith("/"): + return root + "/" + path else: return root + path @@ -19,17 +19,14 @@ def clean_wsgi_headers(raw_headers): Convert WSGI headers from CONTENT_TYPE to Content-Type notation """ cleaned = {} - non_prefixed = [ - 'content_type', - 'content_length', - ] + non_prefixed = ["content_type", "content_length"] for raw_header, value in raw_headers.items(): h = raw_header.lower() - if not h.startswith('http_') and h not in non_prefixed: + if not h.startswith("http_") and h not in non_prefixed: continue - words = h.replace('http_', '', 1).split('_') - cleaned_header = '-'.join([w.capitalize() for w in words]) + words = h.replace("http_", "", 1).split("_") + cleaned_header = "-".join([w.capitalize() for w in words]) cleaned[cleaned_header] = value return cleaned diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 1350ec731..63a1d7b71 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -1,55 +1,47 @@ from django import forms -from django.conf import settings from django.core import paginator from django.db import transaction -from django.http import HttpResponse +from django.http import HttpResponse, Http404 from django.urls import reverse - -from rest_framework import mixins -from rest_framework import permissions as rest_permissions -from rest_framework import response -from rest_framework import views -from rest_framework import viewsets -from rest_framework.decorators import list_route, detail_route -from rest_framework.serializers import ValidationError +from rest_framework import mixins, response, viewsets +from rest_framework.decorators import detail_route, list_route from funkwhale_api.common import preferences -from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.music import models as music_models from funkwhale_api.users.permissions import HasUserPermission -from . import activity -from . import actors -from . import authentication -from . import filters -from . import library -from . import models -from . import permissions -from . import renderers -from . import serializers -from . import tasks -from . import utils -from . import webfinger +from . import ( + actors, + authentication, + filters, + library, + models, + permissions, + renderers, + serializers, + tasks, + utils, + webfinger, +) class FederationMixin(object): def dispatch(self, request, *args, **kwargs): - if not preferences.get('federation__enabled'): + if not preferences.get("federation__enabled"): return HttpResponse(status=405) return super().dispatch(request, *args, **kwargs) class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet): - lookup_field = 'actor' - lookup_value_regex = '[a-z]*' - authentication_classes = [ - authentication.SignatureAuthentication] + lookup_field = "actor" + lookup_value_regex = "[a-z]*" + authentication_classes = [authentication.SignatureAuthentication] permission_classes = [] renderer_classes = [renderers.ActivityPubRenderer] def get_object(self): try: - return actors.SYSTEM_ACTORS[self.kwargs['actor']] + return actors.SYSTEM_ACTORS[self.kwargs["actor"]] except KeyError: raise Http404 @@ -59,27 +51,23 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet): data = actor.system_conf.serialize() return response.Response(data, status=200) - @detail_route(methods=['get', 'post']) + @detail_route(methods=["get", "post"]) def inbox(self, request, *args, **kwargs): system_actor = self.get_object() - handler = getattr(system_actor, '{}_inbox'.format( - request.method.lower() - )) + handler = getattr(system_actor, "{}_inbox".format(request.method.lower())) try: - data = handler(request.data, actor=request.actor) + handler(request.data, actor=request.actor) except NotImplementedError: return response.Response(status=405) return response.Response({}, status=200) - @detail_route(methods=['get', 'post']) + @detail_route(methods=["get", "post"]) def outbox(self, request, *args, **kwargs): system_actor = self.get_object() - handler = getattr(system_actor, '{}_outbox'.format( - request.method.lower() - )) + handler = getattr(system_actor, "{}_outbox".format(request.method.lower())) try: - data = handler(request.data, actor=request.actor) + handler(request.data, actor=request.actor) except NotImplementedError: return response.Response(status=405) return response.Response({}, status=200) @@ -90,45 +78,36 @@ class WellKnownViewSet(viewsets.GenericViewSet): permission_classes = [] renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer] - @list_route(methods=['get']) + @list_route(methods=["get"]) def nodeinfo(self, request, *args, **kwargs): - if not preferences.get('instance__nodeinfo_enabled'): + if not preferences.get("instance__nodeinfo_enabled"): return HttpResponse(status=404) data = { - 'links': [ + "links": [ { - 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', - 'href': utils.full_url( - reverse('api:v1:instance:nodeinfo-2.0') - ) + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": utils.full_url(reverse("api:v1:instance:nodeinfo-2.0")), } ] } return response.Response(data) - @list_route(methods=['get']) + @list_route(methods=["get"]) def webfinger(self, request, *args, **kwargs): - if not preferences.get('federation__enabled'): + if not preferences.get("federation__enabled"): return HttpResponse(status=405) try: - resource_type, resource = webfinger.clean_resource( - request.GET['resource']) - cleaner = getattr(webfinger, 'clean_{}'.format(resource_type)) + resource_type, resource = webfinger.clean_resource(request.GET["resource"]) + cleaner = getattr(webfinger, "clean_{}".format(resource_type)) result = cleaner(resource) except forms.ValidationError as e: - return response.Response({ - 'errors': { - 'resource': e.message - } - }, status=400) + return response.Response({"errors": {"resource": e.message}}, status=400) except KeyError: - return response.Response({ - 'errors': { - 'resource': 'This field is required', - } - }, status=400) + return response.Response( + {"errors": {"resource": "This field is required"}}, status=400 + ) - handler = getattr(self, 'handler_{}'.format(resource_type)) + handler = getattr(self, "handler_{}".format(resource_type)) data = handler(result) return response.Response(data) @@ -140,46 +119,43 @@ class WellKnownViewSet(viewsets.GenericViewSet): class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet): - authentication_classes = [ - authentication.SignatureAuthentication] + authentication_classes = [authentication.SignatureAuthentication] permission_classes = [permissions.LibraryFollower] renderer_classes = [renderers.ActivityPubRenderer] def list(self, request, *args, **kwargs): - page = request.GET.get('page') - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() - qs = music_models.TrackFile.objects.order_by( - '-creation_date' - ).select_related( - 'track__artist', - 'track__album__artist' - ).filter(library_track__isnull=True) + page = request.GET.get("page") + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() + qs = ( + music_models.TrackFile.objects.order_by("-creation_date") + .select_related("track__artist", "track__album__artist") + .filter(library_track__isnull=True) + ) if page is None: conf = { - 'id': utils.full_url(reverse('federation:music:files-list')), - 'page_size': preferences.get( - 'federation__collection_page_size'), - 'items': qs, - 'item_serializer': serializers.AudioSerializer, - 'actor': library, + "id": utils.full_url(reverse("federation:music:files-list")), + "page_size": preferences.get("federation__collection_page_size"), + "items": qs, + "item_serializer": serializers.AudioSerializer, + "actor": library, } serializer = serializers.PaginatedCollectionSerializer(conf) data = serializer.data else: try: page_number = int(page) - except: - return response.Response( - {'page': ['Invalid page number']}, status=400) + except Exception: + return response.Response({"page": ["Invalid page number"]}, status=400) p = paginator.Paginator( - qs, preferences.get('federation__collection_page_size')) + qs, preferences.get("federation__collection_page_size") + ) try: page = p.page(page_number) conf = { - 'id': utils.full_url(reverse('federation:music:files-list')), - 'page': page, - 'item_serializer': serializers.AudioSerializer, - 'actor': library, + "id": utils.full_url(reverse("federation:music:files-list")), + "page": page, + "item_serializer": serializers.AudioSerializer, + "actor": library, } serializer = serializers.CollectionPageSerializer(conf) data = serializer.data @@ -190,134 +166,112 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet): class LibraryViewSet( - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): permission_classes = (HasUserPermission,) - required_permissions = ['federation'] - queryset = models.Library.objects.all().select_related( - 'actor', - 'follow', - ) - lookup_field = 'uuid' + required_permissions = ["federation"] + queryset = models.Library.objects.all().select_related("actor", "follow") + lookup_field = "uuid" filter_class = filters.LibraryFilter serializer_class = serializers.APILibrarySerializer ordering_fields = ( - 'id', - 'creation_date', - 'fetched_date', - 'actor__domain', - 'tracks_count', + "id", + "creation_date", + "fetched_date", + "actor__domain", + "tracks_count", ) - @list_route(methods=['get']) + @list_route(methods=["get"]) def fetch(self, request, *args, **kwargs): - account = request.GET.get('account') + account = request.GET.get("account") if not account: - return response.Response( - {'account': 'This field is mandatory'}, status=400) + return response.Response({"account": "This field is mandatory"}, status=400) data = library.scan_from_account_name(account) return response.Response(data) - @detail_route(methods=['post']) + @detail_route(methods=["post"]) def scan(self, request, *args, **kwargs): library = self.get_object() - serializer = serializers.APILibraryScanSerializer( - data=request.data - ) + serializer = serializers.APILibraryScanSerializer(data=request.data) serializer.is_valid(raise_exception=True) result = tasks.scan_library.delay( - library_id=library.pk, - until=serializer.validated_data.get('until') + library_id=library.pk, until=serializer.validated_data.get("until") ) - return response.Response({'task': result.id}) + return response.Response({"task": result.id}) - @list_route(methods=['get']) + @list_route(methods=["get"]) def following(self, request, *args, **kwargs): - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - queryset = models.Follow.objects.filter( - actor=library_actor - ).select_related( - 'actor', - 'target', - ).order_by('-creation_date') + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + queryset = ( + models.Follow.objects.filter(actor=library_actor) + .select_related("actor", "target") + .order_by("-creation_date") + ) filterset = filters.FollowFilter(request.GET, queryset=queryset) final_qs = filterset.qs serializer = serializers.APIFollowSerializer(final_qs, many=True) - data = { - 'results': serializer.data, - 'count': len(final_qs), - } + data = {"results": serializer.data, "count": len(final_qs)} return response.Response(data) - @list_route(methods=['get', 'patch']) + @list_route(methods=["get", "patch"]) def followers(self, request, *args, **kwargs): - if request.method.lower() == 'patch': - serializer = serializers.APILibraryFollowUpdateSerializer( - data=request.data) + if request.method.lower() == "patch": + serializer = serializers.APILibraryFollowUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) follow = serializer.save() - return response.Response( - serializers.APIFollowSerializer(follow).data - ) + return response.Response(serializers.APIFollowSerializer(follow).data) - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - queryset = models.Follow.objects.filter( - target=library_actor - ).select_related( - 'actor', - 'target', - ).order_by('-creation_date') + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + queryset = ( + models.Follow.objects.filter(target=library_actor) + .select_related("actor", "target") + .order_by("-creation_date") + ) filterset = filters.FollowFilter(request.GET, queryset=queryset) final_qs = filterset.qs serializer = serializers.APIFollowSerializer(final_qs, many=True) - data = { - 'results': serializer.data, - 'count': len(final_qs), - } + data = {"results": serializer.data, "count": len(final_qs)} return response.Response(data) @transaction.atomic def create(self, request, *args, **kwargs): serializer = serializers.APILibraryCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) - library = serializer.save() + serializer.save() return response.Response(serializer.data, status=201) -class LibraryTrackViewSet( - mixins.ListModelMixin, - viewsets.GenericViewSet): +class LibraryTrackViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): permission_classes = (HasUserPermission,) - required_permissions = ['federation'] - queryset = models.LibraryTrack.objects.all().select_related( - 'library__actor', - 'library__follow', - 'local_track_file', - ).prefetch_related('import_jobs') + required_permissions = ["federation"] + queryset = ( + models.LibraryTrack.objects.all() + .select_related("library__actor", "library__follow", "local_track_file") + .prefetch_related("import_jobs") + ) filter_class = filters.LibraryTrackFilter serializer_class = serializers.APILibraryTrackSerializer ordering_fields = ( - 'id', - 'artist_name', - 'title', - 'album_title', - 'creation_date', - 'modification_date', - 'fetched_date', - 'published_date', + "id", + "artist_name", + "title", + "album_title", + "creation_date", + "modification_date", + "fetched_date", + "published_date", ) - @list_route(methods=['post']) + @list_route(methods=["post"]) def action(self, request, *args, **kwargs): - queryset = models.LibraryTrack.objects.filter( - local_track_file__isnull=True) + queryset = models.LibraryTrack.objects.filter(local_track_file__isnull=True) serializer = serializers.LibraryTrackActionSerializer( - request.data, - queryset=queryset, - context={'submitted_by': request.user} + request.data, queryset=queryset, context={"submitted_by": request.user} ) serializer.is_valid(raise_exception=True) result = serializer.save() diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py index f5cb99635..b899fe207 100644 --- a/api/funkwhale_api/federation/webfinger.py +++ b/api/funkwhale_api/federation/webfinger.py @@ -1,43 +1,39 @@ from django import forms from django.conf import settings -from django.urls import reverse from funkwhale_api.common import session -from . import actors -from . import utils -from . import serializers +from . import actors, serializers -VALID_RESOURCE_TYPES = ['acct'] +VALID_RESOURCE_TYPES = ["acct"] def clean_resource(resource_string): if not resource_string: - raise forms.ValidationError('Invalid resource string') + raise forms.ValidationError("Invalid resource string") try: - resource_type, resource = resource_string.split(':', 1) + resource_type, resource = resource_string.split(":", 1) except ValueError: - raise forms.ValidationError('Missing webfinger resource type') + raise forms.ValidationError("Missing webfinger resource type") if resource_type not in VALID_RESOURCE_TYPES: - raise forms.ValidationError('Invalid webfinger resource type') + raise forms.ValidationError("Invalid webfinger resource type") return resource_type, resource def clean_acct(acct_string, ensure_local=True): try: - username, hostname = acct_string.split('@') + username, hostname = acct_string.split("@") except ValueError: - raise forms.ValidationError('Invalid format') + raise forms.ValidationError("Invalid format") if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME: - raise forms.ValidationError( - 'Invalid hostname {}'.format(hostname)) + raise forms.ValidationError("Invalid hostname {}".format(hostname)) if ensure_local and username not in actors.SYSTEM_ACTORS: - raise forms.ValidationError('Invalid username') + raise forms.ValidationError("Invalid username") return username, hostname @@ -45,12 +41,12 @@ def clean_acct(acct_string, ensure_local=True): def get_resource(resource_string): resource_type, resource = clean_resource(resource_string) username, hostname = clean_acct(resource, ensure_local=False) - url = 'https://{}/.well-known/webfinger?resource={}'.format( - hostname, resource_string) + url = "https://{}/.well-known/webfinger?resource={}".format( + hostname, resource_string + ) response = session.get_session().get( - url, - verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, - timeout=5) + url, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, timeout=5 + ) response.raise_for_status() serializer = serializers.ActorWebfingerSerializer(data=response.json()) serializer.is_valid(raise_exception=True) diff --git a/api/funkwhale_api/history/activities.py b/api/funkwhale_api/history/activities.py index e478f9b7f..b63de1f26 100644 --- a/api/funkwhale_api/history/activities.py +++ b/api/funkwhale_api/history/activities.py @@ -1,19 +1,16 @@ -from funkwhale_api.common import channels from funkwhale_api.activity import record +from funkwhale_api.common import channels from . import serializers -record.registry.register_serializer( - serializers.ListeningActivitySerializer) +record.registry.register_serializer(serializers.ListeningActivitySerializer) -@record.registry.register_consumer('history.Listening') +@record.registry.register_consumer("history.Listening") def broadcast_listening_to_instance_activity(data, obj): - if obj.user.privacy_level not in ['instance', 'everyone']: + if obj.user.privacy_level not in ["instance", "everyone"]: return - channels.group_send('instance_activity', { - 'type': 'event.send', - 'text': '', - 'data': data - }) + channels.group_send( + "instance_activity", {"type": "event.send", "text": "", "data": data} + ) diff --git a/api/funkwhale_api/history/admin.py b/api/funkwhale_api/history/admin.py index 5ddfb8998..cbc7f89dd 100644 --- a/api/funkwhale_api/history/admin.py +++ b/api/funkwhale_api/history/admin.py @@ -2,11 +2,9 @@ from django.contrib import admin from . import models + @admin.register(models.Listening) class ListeningAdmin(admin.ModelAdmin): - list_display = ['track', 'creation_date', 'user', 'session_key'] - search_fields = ['track__name', 'user__username'] - list_select_related = [ - 'user', - 'track' - ] + list_display = ["track", "creation_date", "user", "session_key"] + search_fields = ["track__name", "user__username"] + list_select_related = ["user", "track"] diff --git a/api/funkwhale_api/history/factories.py b/api/funkwhale_api/history/factories.py index 86fea64d2..0524eff19 100644 --- a/api/funkwhale_api/history/factories.py +++ b/api/funkwhale_api/history/factories.py @@ -11,4 +11,4 @@ class ListeningFactory(factory.django.DjangoModelFactory): track = factory.SubFactory(factories.TrackFactory) class Meta: - model = 'history.Listening' + model = "history.Listening" diff --git a/api/funkwhale_api/history/migrations/0001_initial.py b/api/funkwhale_api/history/migrations/0001_initial.py index 7b6f950ed..cd2777230 100644 --- a/api/funkwhale_api/history/migrations/0001_initial.py +++ b/api/funkwhale_api/history/migrations/0001_initial.py @@ -9,22 +9,52 @@ import django.utils.timezone class Migration(migrations.Migration): dependencies = [ - ('music', '0008_auto_20160529_1456'), + ("music", "0008_auto_20160529_1456"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Listening', + name="Listening", fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), - ('end_date', models.DateTimeField(null=True, blank=True, default=django.utils.timezone.now)), - ('session_key', models.CharField(null=True, blank=True, max_length=100)), - ('track', models.ForeignKey(related_name='listenings', to='music.Track', on_delete=models.CASCADE)), - ('user', models.ForeignKey(blank=True, null=True, related_name='listenings', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + primary_key=True, + serialize=False, + auto_created=True, + ), + ), + ( + "end_date", + models.DateTimeField( + null=True, blank=True, default=django.utils.timezone.now + ), + ), + ( + "session_key", + models.CharField(null=True, blank=True, max_length=100), + ), + ( + "track", + models.ForeignKey( + related_name="listenings", + to="music.Track", + on_delete=models.CASCADE, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + related_name="listenings", + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), ], - options={ - 'ordering': ('-end_date',), - }, - ), + options={"ordering": ("-end_date",)}, + ) ] diff --git a/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py index d83dbb0a4..efc020925 100644 --- a/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py +++ b/api/funkwhale_api/history/migrations/0002_auto_20180325_1433.py @@ -5,18 +5,13 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('history', '0001_initial'), - ] + dependencies = [("history", "0001_initial")] operations = [ migrations.AlterModelOptions( - name='listening', - options={'ordering': ('-creation_date',)}, + name="listening", options={"ordering": ("-creation_date",)} ), migrations.RenameField( - model_name='listening', - old_name='end_date', - new_name='creation_date', + model_name="listening", old_name="end_date", new_name="creation_date" ), ] diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py index 480461d35..8da4e67cd 100644 --- a/api/funkwhale_api/history/models.py +++ b/api/funkwhale_api/history/models.py @@ -1,26 +1,25 @@ -from django.utils import timezone from django.db import models -from django.core.exceptions import ValidationError +from django.utils import timezone from funkwhale_api.music.models import Track class Listening(models.Model): - creation_date = models.DateTimeField( - default=timezone.now, null=True, blank=True) + creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True) track = models.ForeignKey( - Track, related_name="listenings", on_delete=models.CASCADE) + Track, related_name="listenings", on_delete=models.CASCADE + ) user = models.ForeignKey( - 'users.User', + "users.User", related_name="listenings", null=True, blank=True, - on_delete=models.CASCADE) + on_delete=models.CASCADE, + ) session_key = models.CharField(max_length=100, null=True, blank=True) class Meta: - ordering = ('-creation_date',) + ordering = ("-creation_date",) def get_activity_url(self): - return '{}/listenings/tracks/{}'.format( - self.user.get_activity_url(), self.pk) + return "{}/listenings/tracks/{}".format(self.user.get_activity_url(), self.pk) diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py index 572787ae0..e49322798 100644 --- a/api/funkwhale_api/history/serializers.py +++ b/api/funkwhale_api/history/serializers.py @@ -9,35 +9,27 @@ from . import models class ListeningActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() - object = TrackActivitySerializer(source='track') - actor = UserActivitySerializer(source='user') - published = serializers.DateTimeField(source='creation_date') + object = TrackActivitySerializer(source="track") + actor = UserActivitySerializer(source="user") + published = serializers.DateTimeField(source="creation_date") class Meta: model = models.Listening - fields = [ - 'id', - 'local_id', - 'object', - 'type', - 'actor', - 'published' - ] + fields = ["id", "local_id", "object", "type", "actor", "published"] def get_actor(self, obj): return UserActivitySerializer(obj.user).data def get_type(self, obj): - return 'Listen' + return "Listen" class ListeningSerializer(serializers.ModelSerializer): - class Meta: model = models.Listening - fields = ('id', 'user', 'track', 'creation_date') + fields = ("id", "user", "track", "creation_date") def create(self, validated_data): - validated_data['user'] = self.context['user'] + validated_data["user"] = self.context["user"] return super().create(validated_data) diff --git a/api/funkwhale_api/history/urls.py b/api/funkwhale_api/history/urls.py index 6bd72a8a2..707e95cd7 100644 --- a/api/funkwhale_api/history/urls.py +++ b/api/funkwhale_api/history/urls.py @@ -1,8 +1,8 @@ -from django.conf.urls import include, url +from rest_framework import routers + from . import views -from rest_framework import routers router = routers.SimpleRouter() -router.register(r'listenings', views.ListeningViewSet, 'listenings') +router.register(r"listenings", views.ListeningViewSet, "listenings") urlpatterns = router.urls diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index 3da8b2a38..e104a2aa3 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -1,20 +1,13 @@ -from rest_framework import generics, mixins, viewsets -from rest_framework import permissions -from rest_framework import status -from rest_framework.response import Response -from rest_framework.decorators import detail_route +from rest_framework import mixins, permissions, viewsets from funkwhale_api.activity import record -from funkwhale_api.common.permissions import ConditionalAuthentication -from . import models -from . import serializers +from . import models, serializers class ListeningViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - viewsets.GenericViewSet): + mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): serializer_class = serializers.ListeningSerializer queryset = models.Listening.objects.all() @@ -31,5 +24,5 @@ class ListeningViewSet( def get_serializer_context(self): context = super().get_serializer_context() - context['user'] = self.request.user + context["user"] = self.request.user return context diff --git a/api/funkwhale_api/instance/consumers.py b/api/funkwhale_api/instance/consumers.py index eee5f7f0e..bb213a001 100644 --- a/api/funkwhale_api/instance/consumers.py +++ b/api/funkwhale_api/instance/consumers.py @@ -5,4 +5,4 @@ class InstanceActivityConsumer(JsonAuthConsumer): groups = ["instance_activity"] def event_send(self, message): - self.send_json(message['data']) + self.send_json(message["data"]) diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py index 8ccf80dd9..0edb94482 100644 --- a/api/funkwhale_api/instance/dynamic_preferences_registry.py +++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py @@ -1,93 +1,84 @@ from django.forms import widgets - from dynamic_preferences import types from dynamic_preferences.registries import global_preferences_registry -raven = types.Section('raven') -instance = types.Section('instance') +raven = types.Section("raven") +instance = types.Section("instance") @global_preferences_registry.register class InstanceName(types.StringPreference): show_in_api = True section = instance - name = 'name' - default = '' - verbose_name = 'Public name' - help_text = 'The public name of your instance, displayed in the about page.' - field_kwargs = { - 'required': False, - } + name = "name" + default = "" + verbose_name = "Public name" + help_text = "The public name of your instance, displayed in the about page." + field_kwargs = {"required": False} @global_preferences_registry.register class InstanceShortDescription(types.StringPreference): show_in_api = True section = instance - name = 'short_description' - default = '' - verbose_name = 'Short description' - help_text = 'Instance succinct description, displayed in the about page.' - field_kwargs = { - 'required': False, - } + name = "short_description" + default = "" + verbose_name = "Short description" + help_text = "Instance succinct description, displayed in the about page." + field_kwargs = {"required": False} @global_preferences_registry.register class InstanceLongDescription(types.StringPreference): show_in_api = True section = instance - name = 'long_description' - verbose_name = 'Long description' - default = '' - help_text = 'Instance long description, displayed in the about page (markdown allowed).' + name = "long_description" + verbose_name = "Long description" + default = "" + help_text = ( + "Instance long description, displayed in the about page (markdown allowed)." + ) widget = widgets.Textarea - field_kwargs = { - 'required': False, - } + field_kwargs = {"required": False} @global_preferences_registry.register class RavenDSN(types.StringPreference): show_in_api = True section = raven - name = 'front_dsn' - default = 'https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4' - verbose_name = 'Raven DSN key (front-end)' + name = "front_dsn" + default = "https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4" + verbose_name = "Raven DSN key (front-end)" help_text = ( - 'A Raven DSN key used to report front-ent errors to ' - 'a sentry instance. Keeping the default one will report errors to ' - 'Funkwhale developers.' + "A Raven DSN key used to report front-ent errors to " + "a sentry instance. Keeping the default one will report errors to " + "Funkwhale developers." ) - field_kwargs = { - 'required': False, - } + field_kwargs = {"required": False} @global_preferences_registry.register class RavenEnabled(types.BooleanPreference): show_in_api = True section = raven - name = 'front_enabled' + name = "front_enabled" default = False - verbose_name = ( - 'Report front-end errors with Raven' - ) + verbose_name = "Report front-end errors with Raven" @global_preferences_registry.register class InstanceNodeinfoEnabled(types.BooleanPreference): show_in_api = False section = instance - name = 'nodeinfo_enabled' + name = "nodeinfo_enabled" default = True - verbose_name = 'Enable nodeinfo endpoint' + verbose_name = "Enable nodeinfo endpoint" help_text = ( - 'This endpoint is needed for your about page to work. ' - 'It\'s also helpful for the various monitoring ' - 'tools that map and analyzize the fediverse, ' - 'but you can disable it completely if needed.' + "This endpoint is needed for your about page to work. " + "It's also helpful for the various monitoring " + "tools that map and analyzize the fediverse, " + "but you can disable it completely if needed." ) @@ -95,13 +86,13 @@ class InstanceNodeinfoEnabled(types.BooleanPreference): class InstanceNodeinfoPrivate(types.BooleanPreference): show_in_api = False section = instance - name = 'nodeinfo_private' + name = "nodeinfo_private" default = False - verbose_name = 'Private mode in nodeinfo' + verbose_name = "Private mode in nodeinfo" help_text = ( - 'Indicate in the nodeinfo endpoint that you do not want your instance ' - 'to be tracked by third-party services. ' - 'There is no guarantee these tools will honor this setting though.' + "Indicate in the nodeinfo endpoint that you do not want your instance " + "to be tracked by third-party services. " + "There is no guarantee these tools will honor this setting though." ) @@ -109,10 +100,10 @@ class InstanceNodeinfoPrivate(types.BooleanPreference): class InstanceNodeinfoStatsEnabled(types.BooleanPreference): show_in_api = False section = instance - name = 'nodeinfo_stats_enabled' + name = "nodeinfo_stats_enabled" default = True - verbose_name = 'Enable usage and library stats in nodeinfo endpoint' + verbose_name = "Enable usage and library stats in nodeinfo endpoint" help_text = ( - 'Disable this if you don\'t want to share usage and library statistics ' - 'in the nodeinfo endpoint but don\'t want to disable it completely.' + "Disable this if you don't want to share usage and library statistics " + "in the nodeinfo endpoint but don't want to disable it completely." ) diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py index dbc005af7..0b8f4b3ce 100644 --- a/api/funkwhale_api/instance/nodeinfo.py +++ b/api/funkwhale_api/instance/nodeinfo.py @@ -5,71 +5,46 @@ from funkwhale_api.common import preferences from . import stats - -store = memoize.djangocache.Cache('default') -memo = memoize.Memoizer(store, namespace='instance:stats') +store = memoize.djangocache.Cache("default") +memo = memoize.Memoizer(store, namespace="instance:stats") def get(): - share_stats = preferences.get('instance__nodeinfo_stats_enabled') - private = preferences.get('instance__nodeinfo_private') + share_stats = preferences.get("instance__nodeinfo_stats_enabled") data = { - 'version': '2.0', - 'software': { - 'name': 'funkwhale', - 'version': funkwhale_api.__version__ - }, - 'protocols': ['activitypub'], - 'services': { - 'inbound': [], - 'outbound': [] - }, - 'openRegistrations': preferences.get('users__registration_enabled'), - 'usage': { - 'users': { - 'total': 0, - } - }, - 'metadata': { - 'private': preferences.get('instance__nodeinfo_private'), - 'shortDescription': preferences.get('instance__short_description'), - 'longDescription': preferences.get('instance__long_description'), - 'nodeName': preferences.get('instance__name'), - 'library': { - 'federationEnabled': preferences.get('federation__enabled'), - 'federationNeedsApproval': preferences.get('federation__music_needs_approval'), - 'anonymousCanListen': preferences.get('common__api_authentication_required'), + "version": "2.0", + "software": {"name": "funkwhale", "version": funkwhale_api.__version__}, + "protocols": ["activitypub"], + "services": {"inbound": [], "outbound": []}, + "openRegistrations": preferences.get("users__registration_enabled"), + "usage": {"users": {"total": 0}}, + "metadata": { + "private": preferences.get("instance__nodeinfo_private"), + "shortDescription": preferences.get("instance__short_description"), + "longDescription": preferences.get("instance__long_description"), + "nodeName": preferences.get("instance__name"), + "library": { + "federationEnabled": preferences.get("federation__enabled"), + "federationNeedsApproval": preferences.get( + "federation__music_needs_approval" + ), + "anonymousCanListen": preferences.get( + "common__api_authentication_required" + ), }, - } + }, } if share_stats: - getter = memo( - lambda: stats.get(), - max_age=600 - ) + getter = memo(lambda: stats.get(), max_age=600) statistics = getter() - data['usage']['users']['total'] = statistics['users'] - data['metadata']['library']['tracks'] = { - 'total': statistics['tracks'], - } - data['metadata']['library']['artists'] = { - 'total': statistics['artists'], - } - data['metadata']['library']['albums'] = { - 'total': statistics['albums'], - } - data['metadata']['library']['music'] = { - 'hours': statistics['music_duration'] - } + data["usage"]["users"]["total"] = statistics["users"] + data["metadata"]["library"]["tracks"] = {"total": statistics["tracks"]} + data["metadata"]["library"]["artists"] = {"total": statistics["artists"]} + data["metadata"]["library"]["albums"] = {"total": statistics["albums"]} + data["metadata"]["library"]["music"] = {"hours": statistics["music_duration"]} - data['metadata']['usage'] = { - 'favorites': { - 'tracks': { - 'total': statistics['track_favorites'], - } - }, - 'listenings': { - 'total': statistics['listenings'] - } + data["metadata"]["usage"] = { + "favorites": {"tracks": {"total": statistics["track_favorites"]}}, + "listenings": {"total": statistics["listenings"]}, } return data diff --git a/api/funkwhale_api/instance/stats.py b/api/funkwhale_api/instance/stats.py index 167b333d6..061aade75 100644 --- a/api/funkwhale_api/instance/stats.py +++ b/api/funkwhale_api/instance/stats.py @@ -8,13 +8,13 @@ from funkwhale_api.users.models import User def get(): return { - 'users': get_users(), - 'tracks': get_tracks(), - 'albums': get_albums(), - 'artists': get_artists(), - 'track_favorites': get_track_favorites(), - 'listenings': get_listenings(), - 'music_duration': get_music_duration(), + "users": get_users(), + "tracks": get_tracks(), + "albums": get_albums(), + "artists": get_artists(), + "track_favorites": get_track_favorites(), + "listenings": get_listenings(), + "music_duration": get_music_duration(), } @@ -43,9 +43,7 @@ def get_artists(): def get_music_duration(): - seconds = models.TrackFile.objects.aggregate( - d=Sum('duration'), - )['d'] + seconds = models.TrackFile.objects.aggregate(d=Sum("duration"))["d"] if seconds: return seconds / 3600 return 0 diff --git a/api/funkwhale_api/instance/urls.py b/api/funkwhale_api/instance/urls.py index 7992842c0..05682b1e7 100644 --- a/api/funkwhale_api/instance/urls.py +++ b/api/funkwhale_api/instance/urls.py @@ -2,10 +2,11 @@ from django.conf.urls import url from rest_framework import routers from . import views + admin_router = routers.SimpleRouter() -admin_router.register(r'admin/settings', views.AdminSettings, 'admin-settings') +admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings") urlpatterns = [ - url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'), - url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'), + url(r"^nodeinfo/2.0/$", views.NodeInfo.as_view(), name="nodeinfo-2.0"), + url(r"^settings/$", views.InstanceSettings.as_view(), name="settings"), ] + admin_router.urls diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py index b905acd3e..ea6311033 100644 --- a/api/funkwhale_api/instance/views.py +++ b/api/funkwhale_api/instance/views.py @@ -1,26 +1,22 @@ -from rest_framework import views -from rest_framework.response import Response - from dynamic_preferences.api import serializers from dynamic_preferences.api import viewsets as preferences_viewsets from dynamic_preferences.registries import global_preferences_registry +from rest_framework import views +from rest_framework.response import Response from funkwhale_api.common import preferences from funkwhale_api.users.permissions import HasUserPermission from . import nodeinfo -from . import stats - -NODEINFO_2_CONTENT_TYPE = ( - 'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8' # noqa -) +NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" # noqa class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet): pagination_class = None permission_classes = (HasUserPermission,) - required_permissions = ['settings'] + required_permissions = ["settings"] + class InstanceSettings(views.APIView): permission_classes = [] @@ -29,16 +25,11 @@ class InstanceSettings(views.APIView): def get(self, request, *args, **kwargs): manager = global_preferences_registry.manager() manager.all() - all_preferences = manager.model.objects.all().order_by( - 'section', 'name' - ) + all_preferences = manager.model.objects.all().order_by("section", "name") api_preferences = [ - p - for p in all_preferences - if getattr(p.preference, 'show_in_api', False) + p for p in all_preferences if getattr(p.preference, "show_in_api", False) ] - data = serializers.GlobalPreferenceSerializer( - api_preferences, many=True).data + data = serializers.GlobalPreferenceSerializer(api_preferences, many=True).data return Response(data, status=200) @@ -47,8 +38,7 @@ class NodeInfo(views.APIView): authentication_classes = [] def get(self, request, *args, **kwargs): - if not preferences.get('instance__nodeinfo_enabled'): + if not preferences.get("instance__nodeinfo_enabled"): return Response(status=404) data = nodeinfo.get() - return Response( - data, status=200, content_type=NODEINFO_2_CONTENT_TYPE) + return Response(data, status=200, content_type=NODEINFO_2_CONTENT_TYPE) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 9853b7a61..2f2bde838 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -1,4 +1,3 @@ -from django.db.models import Count from django_filters import rest_framework as filters @@ -7,19 +6,15 @@ from funkwhale_api.music import models as music_models class ManageTrackFileFilterSet(filters.FilterSet): - q = fields.SearchFilter(search_fields=[ - 'track__title', - 'track__album__title', - 'track__artist__name', - 'source', - ]) + q = fields.SearchFilter( + search_fields=[ + "track__title", + "track__album__title", + "track__artist__name", + "source", + ] + ) class Meta: model = music_models.TrackFile - fields = [ - 'q', - 'track__album', - 'track__artist', - 'track', - 'library_track' - ] + fields = ["q", "track__album", "track__artist", "track", "library_track"] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 02300ec06..1c94cf553 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -10,12 +10,7 @@ from . import filters class ManageTrackFileArtistSerializer(serializers.ModelSerializer): class Meta: model = music_models.Artist - fields = [ - 'id', - 'mbid', - 'creation_date', - 'name', - ] + fields = ["id", "mbid", "creation_date", "name"] class ManageTrackFileAlbumSerializer(serializers.ModelSerializer): @@ -24,13 +19,13 @@ class ManageTrackFileAlbumSerializer(serializers.ModelSerializer): class Meta: model = music_models.Album fields = ( - 'id', - 'mbid', - 'title', - 'artist', - 'release_date', - 'cover', - 'creation_date', + "id", + "mbid", + "title", + "artist", + "release_date", + "cover", + "creation_date", ) @@ -40,15 +35,7 @@ class ManageTrackFileTrackSerializer(serializers.ModelSerializer): class Meta: model = music_models.Track - fields = ( - 'id', - 'mbid', - 'title', - 'album', - 'artist', - 'creation_date', - 'position', - ) + fields = ("id", "mbid", "title", "album", "artist", "creation_date", "position") class ManageTrackFileSerializer(serializers.ModelSerializer): @@ -57,24 +44,24 @@ class ManageTrackFileSerializer(serializers.ModelSerializer): class Meta: model = music_models.TrackFile fields = ( - 'id', - 'path', - 'source', - 'filename', - 'mimetype', - 'track', - 'duration', - 'mimetype', - 'bitrate', - 'size', - 'path', - 'library_track', + "id", + "path", + "source", + "filename", + "mimetype", + "track", + "duration", + "mimetype", + "bitrate", + "size", + "path", + "library_track", ) class ManageTrackFileActionSerializer(common_serializers.ActionSerializer): - actions = ['delete'] - dangerous_actions = ['delete'] + actions = ["delete"] + dangerous_actions = ["delete"] filterset_class = filters.ManageTrackFileFilterSet @transaction.atomic diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index c434581ec..60853034f 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -1,11 +1,11 @@ from django.conf.urls import include, url +from rest_framework import routers + from . import views -from rest_framework import routers library_router = routers.SimpleRouter() -library_router.register(r'track-files', views.ManageTrackFileViewSet, 'track-files') +library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files") urlpatterns = [ - url(r'^library/', - include((library_router.urls, 'instance'), namespace='library')), + url(r"^library/", include((library_router.urls, "instance"), namespace="library")) ] diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 74059caa1..8511732c9 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,48 +1,42 @@ -from rest_framework import mixins -from rest_framework import response -from rest_framework import viewsets +from rest_framework import mixins, response, viewsets from rest_framework.decorators import list_route from funkwhale_api.music import models as music_models from funkwhale_api.users.permissions import HasUserPermission -from . import filters -from . import serializers +from . import filters, serializers class ManageTrackFileViewSet( - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet): + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): queryset = ( music_models.TrackFile.objects.all() - .select_related( - 'track__artist', - 'track__album__artist', - 'library_track') - .order_by('-id') + .select_related("track__artist", "track__album__artist", "library_track") + .order_by("-id") ) serializer_class = serializers.ManageTrackFileSerializer filter_class = filters.ManageTrackFileFilterSet permission_classes = (HasUserPermission,) - required_permissions = ['library'] + required_permissions = ["library"] ordering_fields = [ - 'accessed_date', - 'modification_date', - 'creation_date', - 'track__artist__name', - 'bitrate', - 'size', - 'duration', + "accessed_date", + "modification_date", + "creation_date", + "track__artist__name", + "bitrate", + "size", + "duration", ] - @list_route(methods=['post']) + @list_route(methods=["post"]) def action(self, request, *args, **kwargs): queryset = self.get_queryset() serializer = serializers.ManageTrackFileActionSerializer( - request.data, - queryset=queryset, + request.data, queryset=queryset ) serializer.is_valid(raise_exception=True) result = serializer.save() diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py index 1654428ba..a5775acd6 100644 --- a/api/funkwhale_api/music/admin.py +++ b/api/funkwhale_api/music/admin.py @@ -5,85 +5,73 @@ from . import models @admin.register(models.Artist) class ArtistAdmin(admin.ModelAdmin): - list_display = ['name', 'mbid', 'creation_date'] - search_fields = ['name', 'mbid'] + list_display = ["name", "mbid", "creation_date"] + search_fields = ["name", "mbid"] @admin.register(models.Album) class AlbumAdmin(admin.ModelAdmin): - list_display = ['title', 'artist', 'mbid', 'release_date', 'creation_date'] - search_fields = ['title', 'artist__name', 'mbid'] + list_display = ["title", "artist", "mbid", "release_date", "creation_date"] + search_fields = ["title", "artist__name", "mbid"] list_select_related = True @admin.register(models.Track) class TrackAdmin(admin.ModelAdmin): - list_display = ['title', 'artist', 'album', 'mbid'] - search_fields = ['title', 'artist__name', 'album__title', 'mbid'] + list_display = ["title", "artist", "album", "mbid"] + search_fields = ["title", "artist__name", "album__title", "mbid"] list_select_related = True @admin.register(models.ImportBatch) class ImportBatchAdmin(admin.ModelAdmin): - list_display = [ - 'submitted_by', - 'creation_date', - 'import_request', - 'status'] - list_select_related = [ - 'submitted_by', - 'import_request', - ] - list_filter = ['status'] - search_fields = [ - 'import_request__name', 'source', 'batch__pk', 'mbid'] + list_display = ["submitted_by", "creation_date", "import_request", "status"] + list_select_related = ["submitted_by", "import_request"] + list_filter = ["status"] + search_fields = ["import_request__name", "source", "batch__pk", "mbid"] @admin.register(models.ImportJob) class ImportJobAdmin(admin.ModelAdmin): - list_display = ['source', 'batch', 'track_file', 'status', 'mbid'] - list_select_related = [ - 'track_file', - 'batch', - ] - search_fields = ['source', 'batch__pk', 'mbid'] - list_filter = ['status'] + list_display = ["source", "batch", "track_file", "status", "mbid"] + list_select_related = ["track_file", "batch"] + search_fields = ["source", "batch__pk", "mbid"] + list_filter = ["status"] @admin.register(models.Work) class WorkAdmin(admin.ModelAdmin): - list_display = ['title', 'mbid', 'language', 'nature'] + list_display = ["title", "mbid", "language", "nature"] list_select_related = True - search_fields = ['title'] - list_filter = ['language', 'nature'] + search_fields = ["title"] + list_filter = ["language", "nature"] @admin.register(models.Lyrics) class LyricsAdmin(admin.ModelAdmin): - list_display = ['url', 'id', 'url'] + list_display = ["url", "id", "url"] list_select_related = True - search_fields = ['url', 'work__title'] - list_filter = ['work__language'] + search_fields = ["url", "work__title"] + list_filter = ["work__language"] @admin.register(models.TrackFile) class TrackFileAdmin(admin.ModelAdmin): list_display = [ - 'track', - 'audio_file', - 'source', - 'duration', - 'mimetype', - 'size', - 'bitrate' - ] - list_select_related = [ - 'track' + "track", + "audio_file", + "source", + "duration", + "mimetype", + "size", + "bitrate", ] + list_select_related = ["track"] search_fields = [ - 'source', - 'acoustid_track_id', - 'track__title', - 'track__album__title', - 'track__artist__name'] - list_filter = ['mimetype'] + "source", + "acoustid_track_id", + "track__title", + "track__album__title", + "track__artist__name", + ] + list_filter = ["mimetype"] diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index 11423f5b0..2dd4ba303 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -1,79 +1,74 @@ -import factory import os -from funkwhale_api.factories import registry, ManyToManyFromList -from funkwhale_api.federation.factories import ( - LibraryTrackFactory, -) +import factory + +from funkwhale_api.factories import ManyToManyFromList, registry +from funkwhale_api.federation.factories import LibraryTrackFactory from funkwhale_api.users.factories import UserFactory SAMPLES_PATH = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), - 'tests', 'music' + "tests", + "music", ) @registry.register class ArtistFactory(factory.django.DjangoModelFactory): - name = factory.Faker('name') - mbid = factory.Faker('uuid4') + name = factory.Faker("name") + mbid = factory.Faker("uuid4") class Meta: - model = 'music.Artist' + model = "music.Artist" @registry.register class AlbumFactory(factory.django.DjangoModelFactory): - title = factory.Faker('sentence', nb_words=3) - mbid = factory.Faker('uuid4') - release_date = factory.Faker('date_object') + title = factory.Faker("sentence", nb_words=3) + mbid = factory.Faker("uuid4") + release_date = factory.Faker("date_object") cover = factory.django.ImageField() artist = factory.SubFactory(ArtistFactory) - release_group_id = factory.Faker('uuid4') + release_group_id = factory.Faker("uuid4") class Meta: - model = 'music.Album' + model = "music.Album" @registry.register class TrackFactory(factory.django.DjangoModelFactory): - title = factory.Faker('sentence', nb_words=3) - mbid = factory.Faker('uuid4') + title = factory.Faker("sentence", nb_words=3) + mbid = factory.Faker("uuid4") album = factory.SubFactory(AlbumFactory) - artist = factory.SelfAttribute('album.artist') + artist = factory.SelfAttribute("album.artist") position = 1 - tags = ManyToManyFromList('tags') + tags = ManyToManyFromList("tags") class Meta: - model = 'music.Track' + model = "music.Track" @registry.register class TrackFileFactory(factory.django.DjangoModelFactory): track = factory.SubFactory(TrackFactory) audio_file = factory.django.FileField( - from_path=os.path.join(SAMPLES_PATH, 'test.ogg')) + from_path=os.path.join(SAMPLES_PATH, "test.ogg") + ) bitrate = None size = None duration = None class Meta: - model = 'music.TrackFile' + model = "music.TrackFile" class Params: - in_place = factory.Trait( - audio_file=None, - ) + in_place = factory.Trait(audio_file=None) federation = factory.Trait( audio_file=None, library_track=factory.SubFactory(LibraryTrackFactory), - mimetype=factory.LazyAttribute( - lambda o: o.library_track.audio_mimetype - ), - source=factory.LazyAttribute( - lambda o: o.library_track.audio_url - ), + mimetype=factory.LazyAttribute(lambda o: o.library_track.audio_mimetype), + source=factory.LazyAttribute(lambda o: o.library_track.audio_url), ) @@ -82,26 +77,21 @@ class ImportBatchFactory(factory.django.DjangoModelFactory): submitted_by = factory.SubFactory(UserFactory) class Meta: - model = 'music.ImportBatch' + model = "music.ImportBatch" class Params: - federation = factory.Trait( - submitted_by=None, - source='federation', - ) - finished = factory.Trait( - status='finished', - ) + federation = factory.Trait(submitted_by=None, source="federation") + finished = factory.Trait(status="finished") @registry.register class ImportJobFactory(factory.django.DjangoModelFactory): batch = factory.SubFactory(ImportBatchFactory) - source = factory.Faker('url') - mbid = factory.Faker('uuid4') + source = factory.Faker("url") + mbid = factory.Faker("uuid4") class Meta: - model = 'music.ImportJob' + model = "music.ImportJob" class Params: federation = factory.Trait( @@ -110,53 +100,51 @@ class ImportJobFactory(factory.django.DjangoModelFactory): batch=factory.SubFactory(ImportBatchFactory, federation=True), ) finished = factory.Trait( - status='finished', - track_file=factory.SubFactory(TrackFileFactory), - ) - in_place = factory.Trait( - status='finished', - audio_file=None, + status="finished", track_file=factory.SubFactory(TrackFileFactory) ) + in_place = factory.Trait(status="finished", audio_file=None) with_audio_file = factory.Trait( - status='finished', + status="finished", audio_file=factory.django.FileField( - from_path=os.path.join(SAMPLES_PATH, 'test.ogg')), + from_path=os.path.join(SAMPLES_PATH, "test.ogg") + ), ) -@registry.register(name='music.FileImportJob') +@registry.register(name="music.FileImportJob") class FileImportJobFactory(ImportJobFactory): - source = 'file://' + source = "file://" mbid = None audio_file = factory.django.FileField( - from_path=os.path.join(SAMPLES_PATH, 'test.ogg')) + from_path=os.path.join(SAMPLES_PATH, "test.ogg") + ) @registry.register class WorkFactory(factory.django.DjangoModelFactory): - mbid = factory.Faker('uuid4') - language = 'eng' - nature = 'song' - title = factory.Faker('sentence', nb_words=3) + mbid = factory.Faker("uuid4") + language = "eng" + nature = "song" + title = factory.Faker("sentence", nb_words=3) class Meta: - model = 'music.Work' + model = "music.Work" @registry.register class LyricsFactory(factory.django.DjangoModelFactory): work = factory.SubFactory(WorkFactory) - url = factory.Faker('url') - content = factory.Faker('paragraphs', nb=4) + url = factory.Faker("url") + content = factory.Faker("paragraphs", nb=4) class Meta: - model = 'music.Lyrics' + model = "music.Lyrics" @registry.register class TagFactory(factory.django.DjangoModelFactory): - name = factory.SelfAttribute('slug') - slug = factory.Faker('slug') + name = factory.SelfAttribute("slug") + slug = factory.Faker("slug") class Meta: - model = 'taggit.Tag' + model = "taggit.Tag" diff --git a/api/funkwhale_api/music/fake_data.py b/api/funkwhale_api/music/fake_data.py index 892b784ca..e5fd65d8e 100644 --- a/api/funkwhale_api/music/fake_data.py +++ b/api/funkwhale_api/music/fake_data.py @@ -3,20 +3,21 @@ Populates the database with fake data """ import random -from funkwhale_api.music import models from funkwhale_api.music import factories def create_data(count=25): artists = factories.ArtistFactory.create_batch(size=count) for artist in artists: - print('Creating data for', artist) + print("Creating data for", artist) albums = factories.AlbumFactory.create_batch( - artist=artist, size=random.randint(1, 5)) + artist=artist, size=random.randint(1, 5) + ) for album in albums: factories.TrackFileFactory.create_batch( - track__album=album, size=random.randint(3, 18)) + track__album=album, size=random.randint(3, 18) + ) -if __name__ == '__main__': +if __name__ == "__main__": create_data() diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index dc7aafc21..1f73fc9b0 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -1,18 +1,16 @@ from django.db.models import Count - from django_filters import rest_framework as filters from funkwhale_api.common import fields + from . import models class ListenableMixin(filters.FilterSet): - listenable = filters.BooleanFilter(name='_', method='filter_listenable') + listenable = filters.BooleanFilter(name="_", method="filter_listenable") def filter_listenable(self, queryset, name, value): - queryset = queryset.annotate( - files_count=Count('tracks__files') - ) + queryset = queryset.annotate(files_count=Count("tracks__files")) if value: return queryset.filter(files_count__gt=0) else: @@ -20,39 +18,31 @@ class ListenableMixin(filters.FilterSet): class ArtistFilter(ListenableMixin): - q = fields.SearchFilter(search_fields=[ - 'name', - ]) + q = fields.SearchFilter(search_fields=["name"]) class Meta: model = models.Artist fields = { - 'name': ['exact', 'iexact', 'startswith', 'icontains'], - 'listenable': 'exact', + "name": ["exact", "iexact", "startswith", "icontains"], + "listenable": "exact", } class TrackFilter(filters.FilterSet): - q = fields.SearchFilter(search_fields=[ - 'title', - 'album__title', - 'artist__name', - ]) - listenable = filters.BooleanFilter(name='_', method='filter_listenable') + q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) + listenable = filters.BooleanFilter(name="_", method="filter_listenable") class Meta: model = models.Track fields = { - 'title': ['exact', 'iexact', 'startswith', 'icontains'], - 'listenable': ['exact'], - 'artist': ['exact'], - 'album': ['exact'], + "title": ["exact", "iexact", "startswith", "icontains"], + "listenable": ["exact"], + "artist": ["exact"], + "album": ["exact"], } def filter_listenable(self, queryset, name, value): - queryset = queryset.annotate( - files_count=Count('files') - ) + queryset = queryset.annotate(files_count=Count("files")) if value: return queryset.filter(files_count__gt=0) else: @@ -60,46 +50,32 @@ class TrackFilter(filters.FilterSet): class ImportBatchFilter(filters.FilterSet): - q = fields.SearchFilter(search_fields=[ - 'submitted_by__username', - 'source', - ]) + q = fields.SearchFilter(search_fields=["submitted_by__username", "source"]) class Meta: model = models.ImportBatch - fields = { - 'status': ['exact'], - 'source': ['exact'], - 'submitted_by': ['exact'], - } + fields = {"status": ["exact"], "source": ["exact"], "submitted_by": ["exact"]} class ImportJobFilter(filters.FilterSet): - q = fields.SearchFilter(search_fields=[ - 'batch__submitted_by__username', - 'source', - ]) + q = fields.SearchFilter(search_fields=["batch__submitted_by__username", "source"]) class Meta: model = models.ImportJob fields = { - 'batch': ['exact'], - 'batch__status': ['exact'], - 'batch__source': ['exact'], - 'batch__submitted_by': ['exact'], - 'status': ['exact'], - 'source': ['exact'], + "batch": ["exact"], + "batch__status": ["exact"], + "batch__source": ["exact"], + "batch__submitted_by": ["exact"], + "status": ["exact"], + "source": ["exact"], } class AlbumFilter(ListenableMixin): - listenable = filters.BooleanFilter(name='_', method='filter_listenable') - q = fields.SearchFilter(search_fields=[ - 'title', - 'artist__name' - 'source', - ]) + listenable = filters.BooleanFilter(name="_", method="filter_listenable") + q = fields.SearchFilter(search_fields=["title", "artist__name" "source"]) class Meta: model = models.Album - fields = ['listenable', 'q', 'artist'] + fields = ["listenable", "q", "artist"] diff --git a/api/funkwhale_api/music/importers.py b/api/funkwhale_api/music/importers.py index 7e26fe968..ce7ded02b 100644 --- a/api/funkwhale_api/music/importers.py +++ b/api/funkwhale_api/music/importers.py @@ -1,42 +1,43 @@ - - def load(model, *args, **kwargs): importer = registry[model.__name__](model=model) return importer.load(*args, **kwargs) + class Importer(object): def __init__(self, model): self.model = model def load(self, cleaned_data, raw_data, import_hooks): - mbid = cleaned_data.pop('mbid') + mbid = cleaned_data.pop("mbid") m = self.model.objects.update_or_create(mbid=mbid, defaults=cleaned_data)[0] for hook in import_hooks: hook(m, cleaned_data, raw_data) return m + class Mapping(object): """Cast musicbrainz data to funkwhale data and vice-versa""" + def __init__(self, musicbrainz_mapping): self.musicbrainz_mapping = musicbrainz_mapping self._from_musicbrainz = {} self._to_musicbrainz = {} for field_name, conf in self.musicbrainz_mapping.items(): - self._from_musicbrainz[conf['musicbrainz_field_name']] = { - 'field_name': field_name, - 'converter': conf.get('converter', lambda v: v) + self._from_musicbrainz[conf["musicbrainz_field_name"]] = { + "field_name": field_name, + "converter": conf.get("converter", lambda v: v), } self._to_musicbrainz[field_name] = { - 'field_name': conf['musicbrainz_field_name'], - 'converter': conf.get('converter', lambda v: v) + "field_name": conf["musicbrainz_field_name"], + "converter": conf.get("converter", lambda v: v), } - def from_musicbrainz(self, key, value): - return self._from_musicbrainz[key]['field_name'], self._from_musicbrainz[key]['converter'](value) -registry = { - 'Artist': Importer, - 'Track': Importer, - 'Album': Importer, - 'Work': Importer, -} + def from_musicbrainz(self, key, value): + return ( + self._from_musicbrainz[key]["field_name"], + self._from_musicbrainz[key]["converter"](value), + ) + + +registry = {"Artist": Importer, "Track": Importer, "Album": Importer, "Work": Importer} diff --git a/api/funkwhale_api/music/lyrics.py b/api/funkwhale_api/music/lyrics.py index 1ad69ce25..6d5f20e44 100644 --- a/api/funkwhale_api/music/lyrics.py +++ b/api/funkwhale_api/music/lyrics.py @@ -1,27 +1,27 @@ import urllib.request -import html.parser + from bs4 import BeautifulSoup def _get_html(url): with urllib.request.urlopen(url) as response: html = response.read() - return html.decode('utf-8') + return html.decode("utf-8") def extract_content(html): soup = BeautifulSoup(html, "html.parser") - return soup.find_all("div", class_='lyricbox')[0].contents + return soup.find_all("div", class_="lyricbox")[0].contents def clean_content(contents): final_content = "" for e in contents: - if e == '\n': + if e == "\n": continue - if e.name == 'script': + if e.name == "script": continue - if e.name == 'br': + if e.name == "br": final_content += "\n" continue try: diff --git a/api/funkwhale_api/music/management/commands/fix_track_files.py b/api/funkwhale_api/music/management/commands/fix_track_files.py index c18e2b255..988f9bed3 100644 --- a/api/funkwhale_api/music/management/commands/fix_track_files.py +++ b/api/funkwhale_api/music/management/commands/fix_track_files.py @@ -1,29 +1,26 @@ import cacheops -import os - +from django.core.management.base import BaseCommand from django.db import transaction from django.db.models import Q -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError from funkwhale_api.music import models, utils class Command(BaseCommand): - help = 'Run common checks and fix against imported tracks' + help = "Run common checks and fix against imported tracks" def add_arguments(self, parser): parser.add_argument( - '--dry-run', - action='store_true', - dest='dry_run', + "--dry-run", + action="store_true", + dest="dry_run", default=False, - help='Do not execute anything' + help="Do not execute anything", ) def handle(self, *args, **options): - if options['dry_run']: - self.stdout.write('Dry-run on, will not commit anything') + if options["dry_run"]: + self.stdout.write("Dry-run on, will not commit anything") self.fix_mimetypes(**options) self.fix_file_data(**options) self.fix_file_size(**options) @@ -31,75 +28,72 @@ class Command(BaseCommand): @transaction.atomic def fix_mimetypes(self, dry_run, **kwargs): - self.stdout.write('Fixing missing mimetypes...') + self.stdout.write("Fixing missing mimetypes...") matching = models.TrackFile.objects.filter( - source__startswith='file://').exclude(mimetype__startswith='audio/') + source__startswith="file://" + ).exclude(mimetype__startswith="audio/") self.stdout.write( - '[mimetypes] {} entries found with bad or no mimetype'.format( - matching.count())) + "[mimetypes] {} entries found with bad or no mimetype".format( + matching.count() + ) + ) for extension, mimetype in utils.EXTENSION_TO_MIMETYPE.items(): - qs = matching.filter(source__endswith='.{}'.format(extension)) + qs = matching.filter(source__endswith=".{}".format(extension)) self.stdout.write( - '[mimetypes] setting {} {} files to {}'.format( + "[mimetypes] setting {} {} files to {}".format( qs.count(), extension, mimetype - )) + ) + ) if not dry_run: - self.stdout.write('[mimetypes] commiting...') + self.stdout.write("[mimetypes] commiting...") qs.update(mimetype=mimetype) def fix_file_data(self, dry_run, **kwargs): - self.stdout.write('Fixing missing bitrate or length...') + self.stdout.write("Fixing missing bitrate or length...") matching = models.TrackFile.objects.filter( - Q(bitrate__isnull=True) | Q(duration__isnull=True)) + Q(bitrate__isnull=True) | Q(duration__isnull=True) + ) total = matching.count() self.stdout.write( - '[bitrate/length] {} entries found with missing values'.format( - total)) + "[bitrate/length] {} entries found with missing values".format(total) + ) if dry_run: return - for i, tf in enumerate(matching.only('audio_file')): + for i, tf in enumerate(matching.only("audio_file")): self.stdout.write( - '[bitrate/length] {}/{} fixing file #{}'.format( - i+1, total, tf.pk - )) + "[bitrate/length] {}/{} fixing file #{}".format(i + 1, total, tf.pk) + ) try: audio_file = tf.get_audio_file() if audio_file: - with audio_file as f: - data = utils.get_audio_file_data(audio_file) - tf.bitrate = data['bitrate'] - tf.duration = data['length'] - tf.save(update_fields=['duration', 'bitrate']) + data = utils.get_audio_file_data(audio_file) + tf.bitrate = data["bitrate"] + tf.duration = data["length"] + tf.save(update_fields=["duration", "bitrate"]) else: - self.stderr.write('[bitrate/length] no file found') + self.stderr.write("[bitrate/length] no file found") except Exception as e: self.stderr.write( - '[bitrate/length] error with file #{}: {}'.format( - tf.pk, str(e) - ) + "[bitrate/length] error with file #{}: {}".format(tf.pk, str(e)) ) def fix_file_size(self, dry_run, **kwargs): - self.stdout.write('Fixing missing size...') + self.stdout.write("Fixing missing size...") matching = models.TrackFile.objects.filter(size__isnull=True) total = matching.count() - self.stdout.write( - '[size] {} entries found with missing values'.format(total)) + self.stdout.write("[size] {} entries found with missing values".format(total)) if dry_run: return - for i, tf in enumerate(matching.only('size')): + for i, tf in enumerate(matching.only("size")): self.stdout.write( - '[size] {}/{} fixing file #{}'.format( - i+1, total, tf.pk - )) + "[size] {}/{} fixing file #{}".format(i + 1, total, tf.pk) + ) try: tf.size = tf.get_file_size() - tf.save(update_fields=['size']) + tf.save(update_fields=["size"]) except Exception as e: self.stderr.write( - '[size] error with file #{}: {}'.format( - tf.pk, str(e) - ) + "[size] error with file #{}: {}".format(tf.pk, str(e)) ) diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index 4c17c42c0..d2534f6b2 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -1,6 +1,6 @@ -from django import forms import arrow import mutagen +from django import forms NODEFAULT = object() @@ -14,21 +14,17 @@ class UnsupportedTag(KeyError): def get_id3_tag(f, k): - if k == 'pictures': - return f.tags.getall('APIC') + if k == "pictures": + return f.tags.getall("APIC") # First we try to grab the standard key try: return f.tags[k].text[0] except KeyError: pass # then we fallback on parsing non standard tags - all_tags = f.tags.getall('TXXX') + all_tags = f.tags.getall("TXXX") try: - matches = [ - t - for t in all_tags - if t.desc.lower() == k.lower() - ] + matches = [t for t in all_tags if t.desc.lower() == k.lower()] return matches[0].text[0] except (KeyError, IndexError): raise TagNotFound(k) @@ -37,17 +33,19 @@ def get_id3_tag(f, k): def clean_id3_pictures(apic): pictures = [] for p in list(apic): - pictures.append({ - 'mimetype': p.mime, - 'content': p.data, - 'description': p.desc, - 'type': p.type.real, - }) + pictures.append( + { + "mimetype": p.mime, + "content": p.data, + "description": p.desc, + "type": p.type.real, + } + ) return pictures def get_flac_tag(f, k): - if k == 'pictures': + if k == "pictures": return f.pictures try: return f.get(k, [])[0] @@ -58,22 +56,22 @@ def get_flac_tag(f, k): def clean_flac_pictures(apic): pictures = [] for p in list(apic): - pictures.append({ - 'mimetype': p.mime, - 'content': p.data, - 'description': p.desc, - 'type': p.type.real, - }) + pictures.append( + { + "mimetype": p.mime, + "content": p.data, + "description": p.desc, + "type": p.type.real, + } + ) return pictures def get_mp3_recording_id(f, k): try: - return [ - t - for t in f.tags.getall('UFID') - if 'musicbrainz.org' in t.owner - ][0].data.decode('utf-8') + return [t for t in f.tags.getall("UFID") if "musicbrainz.org" in t.owner][ + 0 + ].data.decode("utf-8") except IndexError: raise TagNotFound(k) @@ -86,18 +84,17 @@ def convert_track_number(v): pass try: - return int(v.split('/')[0]) + return int(v.split("/")[0]) except (ValueError, AttributeError, IndexError): pass - class FirstUUIDField(forms.UUIDField): def to_python(self, value): try: # sometimes, Picard leaves to uuids in the field, separated # by a slash - value = value.split('/')[0] + value = value.split("/")[0] except (AttributeError, IndexError, TypeError): pass @@ -105,150 +102,119 @@ class FirstUUIDField(forms.UUIDField): VALIDATION = { - 'musicbrainz_artistid': FirstUUIDField(), - 'musicbrainz_albumid': FirstUUIDField(), - 'musicbrainz_recordingid': FirstUUIDField(), + "musicbrainz_artistid": FirstUUIDField(), + "musicbrainz_albumid": FirstUUIDField(), + "musicbrainz_recordingid": FirstUUIDField(), } CONF = { - 'OggVorbis': { - 'getter': lambda f, k: f[k][0], - 'fields': { - 'track_number': { - 'field': 'TRACKNUMBER', - 'to_application': convert_track_number + "OggVorbis": { + "getter": lambda f, k: f[k][0], + "fields": { + "track_number": { + "field": "TRACKNUMBER", + "to_application": convert_track_number, }, - 'title': {}, - 'artist': {}, - 'album': {}, - 'date': { - 'field': 'date', - 'to_application': lambda v: arrow.get(v).date() - }, - 'musicbrainz_albumid': {}, - 'musicbrainz_artistid': {}, - 'musicbrainz_recordingid': { - 'field': 'musicbrainz_trackid' - }, - } + "title": {}, + "artist": {}, + "album": {}, + "date": {"field": "date", "to_application": lambda v: arrow.get(v).date()}, + "musicbrainz_albumid": {}, + "musicbrainz_artistid": {}, + "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, + }, }, - 'OggTheora': { - 'getter': lambda f, k: f[k][0], - 'fields': { - 'track_number': { - 'field': 'TRACKNUMBER', - 'to_application': convert_track_number + "OggTheora": { + "getter": lambda f, k: f[k][0], + "fields": { + "track_number": { + "field": "TRACKNUMBER", + "to_application": convert_track_number, }, - 'title': {}, - 'artist': {}, - 'album': {}, - 'date': { - 'field': 'date', - 'to_application': lambda v: arrow.get(v).date() - }, - 'musicbrainz_albumid': { - 'field': 'MusicBrainz Album Id' - }, - 'musicbrainz_artistid': { - 'field': 'MusicBrainz Artist Id' - }, - 'musicbrainz_recordingid': { - 'field': 'MusicBrainz Track Id' - }, - } + "title": {}, + "artist": {}, + "album": {}, + "date": {"field": "date", "to_application": lambda v: arrow.get(v).date()}, + "musicbrainz_albumid": {"field": "MusicBrainz Album Id"}, + "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"}, + "musicbrainz_recordingid": {"field": "MusicBrainz Track Id"}, + }, }, - 'MP3': { - 'getter': get_id3_tag, - 'clean_pictures': clean_id3_pictures, - 'fields': { - 'track_number': { - 'field': 'TRCK', - 'to_application': convert_track_number + "MP3": { + "getter": get_id3_tag, + "clean_pictures": clean_id3_pictures, + "fields": { + "track_number": {"field": "TRCK", "to_application": convert_track_number}, + "title": {"field": "TIT2"}, + "artist": {"field": "TPE1"}, + "album": {"field": "TALB"}, + "date": { + "field": "TDRC", + "to_application": lambda v: arrow.get(str(v)).date(), }, - 'title': { - 'field': 'TIT2' + "musicbrainz_albumid": {"field": "MusicBrainz Album Id"}, + "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"}, + "musicbrainz_recordingid": { + "field": "UFID", + "getter": get_mp3_recording_id, }, - 'artist': { - 'field': 'TPE1' - }, - 'album': { - 'field': 'TALB' - }, - 'date': { - 'field': 'TDRC', - 'to_application': lambda v: arrow.get(str(v)).date() - }, - 'musicbrainz_albumid': { - 'field': 'MusicBrainz Album Id' - }, - 'musicbrainz_artistid': { - 'field': 'MusicBrainz Artist Id' - }, - 'musicbrainz_recordingid': { - 'field': 'UFID', - 'getter': get_mp3_recording_id, - }, - 'pictures': {}, - } + "pictures": {}, + }, }, - 'FLAC': { - 'getter': get_flac_tag, - 'clean_pictures': clean_flac_pictures, - 'fields': { - 'track_number': { - 'field': 'tracknumber', - 'to_application': convert_track_number + "FLAC": { + "getter": get_flac_tag, + "clean_pictures": clean_flac_pictures, + "fields": { + "track_number": { + "field": "tracknumber", + "to_application": convert_track_number, }, - 'title': {}, - 'artist': {}, - 'album': {}, - 'date': { - 'field': 'date', - 'to_application': lambda v: arrow.get(str(v)).date() + "title": {}, + "artist": {}, + "album": {}, + "date": { + "field": "date", + "to_application": lambda v: arrow.get(str(v)).date(), }, - 'musicbrainz_albumid': {}, - 'musicbrainz_artistid': {}, - 'musicbrainz_recordingid': { - 'field': 'musicbrainz_trackid' - }, - 'test': {}, - 'pictures': {}, - } + "musicbrainz_albumid": {}, + "musicbrainz_artistid": {}, + "musicbrainz_recordingid": {"field": "musicbrainz_trackid"}, + "test": {}, + "pictures": {}, + }, }, } class Metadata(object): - def __init__(self, path): self._file = mutagen.File(path) if self._file is None: - raise ValueError('Cannot parse metadata from {}'.format(path)) + raise ValueError("Cannot parse metadata from {}".format(path)) ft = self.get_file_type(self._file) try: self._conf = CONF[ft] except KeyError: - raise ValueError('Unsupported format {}'.format(ft)) + raise ValueError("Unsupported format {}".format(ft)) def get_file_type(self, f): return f.__class__.__name__ def get(self, key, default=NODEFAULT): try: - field_conf = self._conf['fields'][key] + field_conf = self._conf["fields"][key] except KeyError: - raise UnsupportedTag( - '{} is not supported for this file format'.format(key)) - real_key = field_conf.get('field', key) + raise UnsupportedTag("{} is not supported for this file format".format(key)) + real_key = field_conf.get("field", key) try: - getter = field_conf.get('getter', self._conf['getter']) + getter = field_conf.get("getter", self._conf["getter"]) v = getter(self._file, real_key) except KeyError: if default == NODEFAULT: raise TagNotFound(real_key) return default - converter = field_conf.get('to_application') + converter = field_conf.get("to_application") if converter: v = converter(v) field = VALIDATION.get(key) @@ -256,15 +222,15 @@ class Metadata(object): v = field.to_python(v) return v - def get_picture(self, picture_type='cover_front'): + def get_picture(self, picture_type="cover_front"): ptype = getattr(mutagen.id3.PictureType, picture_type.upper()) try: - pictures = self.get('pictures') + pictures = self.get("pictures") except (UnsupportedTag, TagNotFound): return - cleaner = self._conf.get('clean_pictures', lambda v: v) + cleaner = self._conf.get("clean_pictures", lambda v: v) pictures = cleaner(pictures) for p in pictures: - if p['type'] == ptype: + if p["type"] == ptype: return p diff --git a/api/funkwhale_api/music/migrations/0001_initial.py b/api/funkwhale_api/music/migrations/0001_initial.py index 265b81577..0bb12342d 100644 --- a/api/funkwhale_api/music/migrations/0001_initial.py +++ b/api/funkwhale_api/music/migrations/0001_initial.py @@ -8,82 +8,183 @@ import django.utils.timezone class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( - name='Album', + name="Album", fields=[ - ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), - ('mbid', models.UUIDField(editable=False, blank=True, null=True)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('title', models.CharField(max_length=255)), - ('release_date', models.DateField()), - ('type', models.CharField(default='album', choices=[('album', 'Album')], max_length=30)), + ( + "id", + models.AutoField( + primary_key=True, + auto_created=True, + serialize=False, + verbose_name="ID", + ), + ), + ("mbid", models.UUIDField(editable=False, blank=True, null=True)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("title", models.CharField(max_length=255)), + ("release_date", models.DateField()), + ( + "type", + models.CharField( + default="album", choices=[("album", "Album")], max_length=30 + ), + ), ], - options={ - 'abstract': False, - }, + options={"abstract": False}, ), migrations.CreateModel( - name='Artist', + name="Artist", fields=[ - ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), - ('mbid', models.UUIDField(editable=False, blank=True, null=True)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('name', models.CharField(max_length=255)), + ( + "id", + models.AutoField( + primary_key=True, + auto_created=True, + serialize=False, + verbose_name="ID", + ), + ), + ("mbid", models.UUIDField(editable=False, blank=True, null=True)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("name", models.CharField(max_length=255)), ], - options={ - 'abstract': False, - }, + options={"abstract": False}, ), migrations.CreateModel( - name='ImportBatch', + name="ImportBatch", fields=[ - ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('submitted_by', models.ForeignKey(related_name='imports', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + primary_key=True, + auto_created=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "submitted_by", + models.ForeignKey( + related_name="imports", + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), ], ), migrations.CreateModel( - name='ImportJob', + name="ImportJob", fields=[ - ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), - ('source', models.URLField()), - ('mbid', models.UUIDField(editable=False)), - ('status', models.CharField(default='pending', choices=[('pending', 'Pending'), ('finished', 'finished')], max_length=30)), - ('batch', models.ForeignKey(related_name='jobs', to='music.ImportBatch', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + primary_key=True, + auto_created=True, + serialize=False, + verbose_name="ID", + ), + ), + ("source", models.URLField()), + ("mbid", models.UUIDField(editable=False)), + ( + "status", + models.CharField( + default="pending", + choices=[("pending", "Pending"), ("finished", "finished")], + max_length=30, + ), + ), + ( + "batch", + models.ForeignKey( + related_name="jobs", + to="music.ImportBatch", + on_delete=models.CASCADE, + ), + ), ], ), migrations.CreateModel( - name='Track', + name="Track", fields=[ - ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), - ('mbid', models.UUIDField(editable=False, blank=True, null=True)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('title', models.CharField(max_length=255)), - ('album', models.ForeignKey(related_name='tracks', blank=True, null=True, to='music.Album', on_delete=models.CASCADE)), - ('artist', models.ForeignKey(related_name='tracks', to='music.Artist', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + primary_key=True, + auto_created=True, + serialize=False, + verbose_name="ID", + ), + ), + ("mbid", models.UUIDField(editable=False, blank=True, null=True)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("title", models.CharField(max_length=255)), + ( + "album", + models.ForeignKey( + related_name="tracks", + blank=True, + null=True, + to="music.Album", + on_delete=models.CASCADE, + ), + ), + ( + "artist", + models.ForeignKey( + related_name="tracks", + to="music.Artist", + on_delete=models.CASCADE, + ), + ), ], - options={ - 'abstract': False, - }, + options={"abstract": False}, ), migrations.CreateModel( - name='TrackFile', + name="TrackFile", fields=[ - ('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), - ('audio_file', models.FileField(upload_to='tracks')), - ('source', models.URLField(blank=True, null=True)), - ('duration', models.IntegerField(blank=True, null=True)), - ('track', models.ForeignKey(related_name='files', to='music.Track', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + primary_key=True, + auto_created=True, + serialize=False, + verbose_name="ID", + ), + ), + ("audio_file", models.FileField(upload_to="tracks")), + ("source", models.URLField(blank=True, null=True)), + ("duration", models.IntegerField(blank=True, null=True)), + ( + "track", + models.ForeignKey( + related_name="files", to="music.Track", on_delete=models.CASCADE + ), + ), ], ), migrations.AddField( - model_name='album', - name='artist', - field=models.ForeignKey(related_name='albums', to='music.Artist', on_delete=models.CASCADE), + model_name="album", + name="artist", + field=models.ForeignKey( + related_name="albums", to="music.Artist", on_delete=models.CASCADE + ), ), ] diff --git a/api/funkwhale_api/music/migrations/0002_auto_20151215_1645.py b/api/funkwhale_api/music/migrations/0002_auto_20151215_1645.py index 1b54a5cfc..094c679da 100644 --- a/api/funkwhale_api/music/migrations/0002_auto_20151215_1645.py +++ b/api/funkwhale_api/music/migrations/0002_auto_20151215_1645.py @@ -6,35 +6,31 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0001_initial'), - ] + dependencies = [("music", "0001_initial")] operations = [ migrations.AlterModelOptions( - name='album', - options={'ordering': ['-creation_date']}, + name="album", options={"ordering": ["-creation_date"]} ), migrations.AlterModelOptions( - name='artist', - options={'ordering': ['-creation_date']}, + name="artist", options={"ordering": ["-creation_date"]} ), migrations.AlterModelOptions( - name='importbatch', - options={'ordering': ['-creation_date']}, + name="importbatch", options={"ordering": ["-creation_date"]} ), migrations.AlterModelOptions( - name='track', - options={'ordering': ['-creation_date']}, + name="track", options={"ordering": ["-creation_date"]} ), migrations.AddField( - model_name='album', - name='cover', - field=models.ImageField(upload_to='albums/covers/%Y/%m/%d', null=True, blank=True), + model_name="album", + name="cover", + field=models.ImageField( + upload_to="albums/covers/%Y/%m/%d", null=True, blank=True + ), ), migrations.AlterField( - model_name='trackfile', - name='audio_file', - field=models.FileField(upload_to='tracks/%Y/%m/%d'), + model_name="trackfile", + name="audio_file", + field=models.FileField(upload_to="tracks/%Y/%m/%d"), ), ] diff --git a/api/funkwhale_api/music/migrations/0003_auto_20151222_2233.py b/api/funkwhale_api/music/migrations/0003_auto_20151222_2233.py index 060957dc1..d8337a781 100644 --- a/api/funkwhale_api/music/migrations/0003_auto_20151222_2233.py +++ b/api/funkwhale_api/music/migrations/0003_auto_20151222_2233.py @@ -6,14 +6,10 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0002_auto_20151215_1645'), - ] + dependencies = [("music", "0002_auto_20151215_1645")] operations = [ migrations.AlterField( - model_name='album', - name='release_date', - field=models.DateField(null=True), - ), + model_name="album", name="release_date", field=models.DateField(null=True) + ) ] diff --git a/api/funkwhale_api/music/migrations/0004_track_tags.py b/api/funkwhale_api/music/migrations/0004_track_tags.py index f95b08b0e..b999a7031 100644 --- a/api/funkwhale_api/music/migrations/0004_track_tags.py +++ b/api/funkwhale_api/music/migrations/0004_track_tags.py @@ -1,21 +1,26 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('taggit', '0002_auto_20150616_2121'), - ('music', '0003_auto_20151222_2233'), + ("taggit", "0002_auto_20150616_2121"), + ("music", "0003_auto_20151222_2233"), ] operations = [ migrations.AddField( - model_name='track', - name='tags', - field=taggit.managers.TaggableManager(verbose_name='Tags', help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag'), - ), + model_name="track", + name="tags", + field=taggit.managers.TaggableManager( + verbose_name="Tags", + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0005_deduplicate.py b/api/funkwhale_api/music/migrations/0005_deduplicate.py index 82dca0caa..0dfdd78f4 100644 --- a/api/funkwhale_api/music/migrations/0005_deduplicate.py +++ b/api/funkwhale_api/music/migrations/0005_deduplicate.py @@ -5,7 +5,14 @@ from django.db import migrations, models def get_duplicates(model): - return [i['mbid'] for i in model.objects.values('mbid').annotate(idcount=models.Count('mbid')).order_by('-idcount') if i['idcount'] > 1] + return [ + i["mbid"] + for i in model.objects.values("mbid") + .annotate(idcount=models.Count("mbid")) + .order_by("-idcount") + if i["idcount"] > 1 + ] + def deduplicate(apps, schema_editor): Artist = apps.get_model("music", "Artist") @@ -13,28 +20,25 @@ def deduplicate(apps, schema_editor): Track = apps.get_model("music", "Track") for mbid in get_duplicates(Artist): - ref = Artist.objects.filter(mbid=mbid).order_by('pk').first() + ref = Artist.objects.filter(mbid=mbid).order_by("pk").first() duplicates = Artist.objects.filter(mbid=mbid).exclude(pk=ref.pk) Album.objects.filter(artist__in=duplicates).update(artist=ref) Track.objects.filter(artist__in=duplicates).update(artist=ref) duplicates.delete() for mbid in get_duplicates(Album): - ref = Album.objects.filter(mbid=mbid).order_by('pk').first() + ref = Album.objects.filter(mbid=mbid).order_by("pk").first() duplicates = Album.objects.filter(mbid=mbid).exclude(pk=ref.pk) Track.objects.filter(album__in=duplicates).update(album=ref) duplicates.delete() + def rewind(*args, **kwargs): pass class Migration(migrations.Migration): - dependencies = [ - ('music', '0004_track_tags'), - ] + dependencies = [("music", "0004_track_tags")] - operations = [ - migrations.RunPython(deduplicate, rewind), - ] + operations = [migrations.RunPython(deduplicate, rewind)] diff --git a/api/funkwhale_api/music/migrations/0006_unique_mbid.py b/api/funkwhale_api/music/migrations/0006_unique_mbid.py index e13e3a743..7d926e373 100644 --- a/api/funkwhale_api/music/migrations/0006_unique_mbid.py +++ b/api/funkwhale_api/music/migrations/0006_unique_mbid.py @@ -3,26 +3,31 @@ from __future__ import unicode_literals from django.db import migrations, models + class Migration(migrations.Migration): - dependencies = [ - ('music', '0005_deduplicate'), - ] + dependencies = [("music", "0005_deduplicate")] operations = [ migrations.AlterField( - model_name='album', - name='mbid', - field=models.UUIDField(null=True, editable=False, unique=True, blank=True, db_index=True), + model_name="album", + name="mbid", + field=models.UUIDField( + null=True, editable=False, unique=True, blank=True, db_index=True + ), ), migrations.AlterField( - model_name='artist', - name='mbid', - field=models.UUIDField(null=True, editable=False, unique=True, blank=True, db_index=True), + model_name="artist", + name="mbid", + field=models.UUIDField( + null=True, editable=False, unique=True, blank=True, db_index=True + ), ), migrations.AlterField( - model_name='track', - name='mbid', - field=models.UUIDField(null=True, editable=False, unique=True, blank=True, db_index=True), + model_name="track", + name="mbid", + field=models.UUIDField( + null=True, editable=False, unique=True, blank=True, db_index=True + ), ), ] diff --git a/api/funkwhale_api/music/migrations/0007_track_position.py b/api/funkwhale_api/music/migrations/0007_track_position.py index 089e0128f..d43dcaea3 100644 --- a/api/funkwhale_api/music/migrations/0007_track_position.py +++ b/api/funkwhale_api/music/migrations/0007_track_position.py @@ -6,14 +6,12 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0006_unique_mbid'), - ] + dependencies = [("music", "0006_unique_mbid")] operations = [ migrations.AddField( - model_name='track', - name='position', + model_name="track", + name="position", field=models.PositiveIntegerField(blank=True, null=True), - ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0008_auto_20160529_1456.py b/api/funkwhale_api/music/migrations/0008_auto_20160529_1456.py index e7fa5c8f4..8812c65a4 100644 --- a/api/funkwhale_api/music/migrations/0008_auto_20160529_1456.py +++ b/api/funkwhale_api/music/migrations/0008_auto_20160529_1456.py @@ -6,24 +6,22 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0007_track_position'), - ] + dependencies = [("music", "0007_track_position")] operations = [ migrations.AlterField( - model_name='album', - name='mbid', + model_name="album", + name="mbid", field=models.UUIDField(null=True, db_index=True, unique=True, blank=True), ), migrations.AlterField( - model_name='artist', - name='mbid', + model_name="artist", + name="mbid", field=models.UUIDField(null=True, db_index=True, unique=True, blank=True), ), migrations.AlterField( - model_name='track', - name='mbid', + model_name="track", + name="mbid", field=models.UUIDField(null=True, db_index=True, unique=True, blank=True), ), ] diff --git a/api/funkwhale_api/music/migrations/0009_auto_20160920_1614.py b/api/funkwhale_api/music/migrations/0009_auto_20160920_1614.py index 3a3d93989..eff0f82a0 100644 --- a/api/funkwhale_api/music/migrations/0009_auto_20160920_1614.py +++ b/api/funkwhale_api/music/migrations/0009_auto_20160920_1614.py @@ -3,47 +3,75 @@ from __future__ import unicode_literals from django.db import migrations, models import django.utils.timezone -import versatileimagefield.fields class Migration(migrations.Migration): - dependencies = [ - ('music', '0008_auto_20160529_1456'), - ] + dependencies = [("music", "0008_auto_20160529_1456")] operations = [ migrations.CreateModel( - name='Lyrics', + name="Lyrics", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)), - ('url', models.URLField()), - ('content', models.TextField(null=True, blank=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + verbose_name="ID", + serialize=False, + ), + ), + ("url", models.URLField()), + ("content", models.TextField(null=True, blank=True)), ], ), migrations.CreateModel( - name='Work', + name="Work", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)), - ('mbid', models.UUIDField(unique=True, null=True, db_index=True, blank=True)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('language', models.CharField(max_length=20)), - ('nature', models.CharField(max_length=50)), - ('title', models.CharField(max_length=255)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + verbose_name="ID", + serialize=False, + ), + ), + ( + "mbid", + models.UUIDField(unique=True, null=True, db_index=True, blank=True), + ), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("language", models.CharField(max_length=20)), + ("nature", models.CharField(max_length=50)), + ("title", models.CharField(max_length=255)), ], - options={ - 'ordering': ['-creation_date'], - 'abstract': False, - }, + options={"ordering": ["-creation_date"], "abstract": False}, ), migrations.AddField( - model_name='lyrics', - name='work', - field=models.ForeignKey(related_name='lyrics', to='music.Work', blank=True, null=True, on_delete=models.CASCADE), + model_name="lyrics", + name="work", + field=models.ForeignKey( + related_name="lyrics", + to="music.Work", + blank=True, + null=True, + on_delete=models.CASCADE, + ), ), migrations.AddField( - model_name='track', - name='work', - field=models.ForeignKey(related_name='tracks', to='music.Work', blank=True, null=True, on_delete=models.CASCADE), + model_name="track", + name="work", + field=models.ForeignKey( + related_name="tracks", + to="music.Work", + blank=True, + null=True, + on_delete=models.CASCADE, + ), ), ] diff --git a/api/funkwhale_api/music/migrations/0010_auto_20160920_1742.py b/api/funkwhale_api/music/migrations/0010_auto_20160920_1742.py index 03ac05793..2b5ce935b 100644 --- a/api/funkwhale_api/music/migrations/0010_auto_20160920_1742.py +++ b/api/funkwhale_api/music/migrations/0010_auto_20160920_1742.py @@ -2,19 +2,14 @@ from __future__ import unicode_literals from django.db import migrations, models -import versatileimagefield.fields class Migration(migrations.Migration): - dependencies = [ - ('music', '0009_auto_20160920_1614'), - ] + dependencies = [("music", "0009_auto_20160920_1614")] operations = [ migrations.AlterField( - model_name='lyrics', - name='url', - field=models.URLField(unique=True), - ), + model_name="lyrics", name="url", field=models.URLField(unique=True) + ) ] diff --git a/api/funkwhale_api/music/migrations/0011_rename_files.py b/api/funkwhale_api/music/migrations/0011_rename_files.py index 1c59535f5..2aafb126c 100644 --- a/api/funkwhale_api/music/migrations/0011_rename_files.py +++ b/api/funkwhale_api/music/migrations/0011_rename_files.py @@ -1,9 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import os from django.db import migrations, models -from funkwhale_api.common.utils import rename_file def rename_files(apps, schema_editor): @@ -47,15 +45,13 @@ def rewind(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('music', '0010_auto_20160920_1742'), - ] + dependencies = [("music", "0010_auto_20160920_1742")] operations = [ migrations.AlterField( - model_name='trackfile', - name='audio_file', - field=models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255), + model_name="trackfile", + name="audio_file", + field=models.FileField(upload_to="tracks/%Y/%m/%d", max_length=255), ), migrations.RunPython(rename_files, rewind), ] diff --git a/api/funkwhale_api/music/migrations/0012_auto_20161122_1905.py b/api/funkwhale_api/music/migrations/0012_auto_20161122_1905.py index 8d7e25246..0cf1e44f0 100644 --- a/api/funkwhale_api/music/migrations/0012_auto_20161122_1905.py +++ b/api/funkwhale_api/music/migrations/0012_auto_20161122_1905.py @@ -1,20 +1,20 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations import versatileimagefield.fields class Migration(migrations.Migration): - dependencies = [ - ('music', '0011_rename_files'), - ] + dependencies = [("music", "0011_rename_files")] operations = [ migrations.AlterField( - model_name='album', - name='cover', - field=versatileimagefield.fields.VersatileImageField(null=True, blank=True, upload_to='albums/covers/%Y/%m/%d'), - ), + model_name="album", + name="cover", + field=versatileimagefield.fields.VersatileImageField( + null=True, blank=True, upload_to="albums/covers/%Y/%m/%d" + ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0013_auto_20171213_2211.py b/api/funkwhale_api/music/migrations/0013_auto_20171213_2211.py index 00ccbb621..2874aa81f 100644 --- a/api/funkwhale_api/music/migrations/0013_auto_20171213_2211.py +++ b/api/funkwhale_api/music/migrations/0013_auto_20171213_2211.py @@ -7,22 +7,16 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0012_auto_20161122_1905'), - ] + dependencies = [("music", "0012_auto_20161122_1905")] operations = [ + migrations.AlterModelOptions(name="importjob", options={"ordering": ("id",)}), migrations.AlterModelOptions( - name='importjob', - options={'ordering': ('id',)}, - ), - migrations.AlterModelOptions( - name='track', - options={'ordering': ['album', 'position']}, + name="track", options={"ordering": ["album", "position"]} ), migrations.AddField( - model_name='album', - name='release_group_id', + model_name="album", + name="release_group_id", field=models.UUIDField(blank=True, null=True), ), ] diff --git a/api/funkwhale_api/music/migrations/0014_importjob_track_file.py b/api/funkwhale_api/music/migrations/0014_importjob_track_file.py index 6950fd3c1..004e247ea 100644 --- a/api/funkwhale_api/music/migrations/0014_importjob_track_file.py +++ b/api/funkwhale_api/music/migrations/0014_importjob_track_file.py @@ -8,14 +8,18 @@ import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('music', '0013_auto_20171213_2211'), - ] + dependencies = [("music", "0013_auto_20171213_2211")] operations = [ migrations.AddField( - model_name='importjob', - name='track_file', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='music.TrackFile'), - ), + model_name="importjob", + name="track_file", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="jobs", + to="music.TrackFile", + ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0015_bind_track_file_to_import_job.py b/api/funkwhale_api/music/migrations/0015_bind_track_file_to_import_job.py index edb5e6470..c8bd1c5e3 100644 --- a/api/funkwhale_api/music/migrations/0015_bind_track_file_to_import_job.py +++ b/api/funkwhale_api/music/migrations/0015_bind_track_file_to_import_job.py @@ -1,22 +1,20 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import os -from django.db import migrations, models -from funkwhale_api.common.utils import rename_file +from django.db import migrations def bind_jobs(apps, schema_editor): TrackFile = apps.get_model("music", "TrackFile") ImportJob = apps.get_model("music", "ImportJob") - for job in ImportJob.objects.all().only('mbid'): + for job in ImportJob.objects.all().only("mbid"): f = TrackFile.objects.filter(track__mbid=job.mbid).first() if not f: - print('No file for mbid {}'.format(job.mbid)) + print("No file for mbid {}".format(job.mbid)) continue job.track_file = f - job.save(update_fields=['track_file']) + job.save(update_fields=["track_file"]) def rewind(apps, schema_editor): @@ -25,10 +23,6 @@ def rewind(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('music', '0014_importjob_track_file'), - ] + dependencies = [("music", "0014_importjob_track_file")] - operations = [ - migrations.RunPython(bind_jobs, rewind), - ] + operations = [migrations.RunPython(bind_jobs, rewind)] diff --git a/api/funkwhale_api/music/migrations/0016_trackfile_acoustid_track_id.py b/api/funkwhale_api/music/migrations/0016_trackfile_acoustid_track_id.py index 21d8ce8ea..467fb0eef 100644 --- a/api/funkwhale_api/music/migrations/0016_trackfile_acoustid_track_id.py +++ b/api/funkwhale_api/music/migrations/0016_trackfile_acoustid_track_id.py @@ -5,14 +5,12 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0015_bind_track_file_to_import_job'), - ] + dependencies = [("music", "0015_bind_track_file_to_import_job")] operations = [ migrations.AddField( - model_name='trackfile', - name='acoustid_track_id', + model_name="trackfile", + name="acoustid_track_id", field=models.UUIDField(blank=True, null=True), - ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0017_auto_20171227_1728.py b/api/funkwhale_api/music/migrations/0017_auto_20171227_1728.py index dfca66437..10a8ed1e8 100644 --- a/api/funkwhale_api/music/migrations/0017_auto_20171227_1728.py +++ b/api/funkwhale_api/music/migrations/0017_auto_20171227_1728.py @@ -5,24 +5,28 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0016_trackfile_acoustid_track_id'), - ] + dependencies = [("music", "0016_trackfile_acoustid_track_id")] operations = [ migrations.AddField( - model_name='importbatch', - name='source', - field=models.CharField(choices=[('api', 'api'), ('shell', 'shell')], default='api', max_length=30), + model_name="importbatch", + name="source", + field=models.CharField( + choices=[("api", "api"), ("shell", "shell")], + default="api", + max_length=30, + ), ), migrations.AddField( - model_name='importjob', - name='audio_file', - field=models.FileField(blank=True, max_length=255, null=True, upload_to='imports/%Y/%m/%d'), + model_name="importjob", + name="audio_file", + field=models.FileField( + blank=True, max_length=255, null=True, upload_to="imports/%Y/%m/%d" + ), ), migrations.AlterField( - model_name='importjob', - name='mbid', + model_name="importjob", + name="mbid", field=models.UUIDField(blank=True, editable=False, null=True), ), ] diff --git a/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py b/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py index c45298798..bfc26b011 100644 --- a/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py +++ b/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py @@ -5,24 +5,31 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0017_auto_20171227_1728'), - ] + dependencies = [("music", "0017_auto_20171227_1728")] operations = [ migrations.AddField( - model_name='trackfile', - name='mimetype', + model_name="trackfile", + name="mimetype", field=models.CharField(blank=True, max_length=200, null=True), ), migrations.AlterField( - model_name='importjob', - name='source', + model_name="importjob", + name="source", field=models.CharField(max_length=500), ), migrations.AlterField( - model_name='importjob', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished'), ('errored', 'Errored'), ('skipped', 'Skipped')], default='pending', max_length=30), + model_name="importjob", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("finished", "Finished"), + ("errored", "Errored"), + ("skipped", "Skipped"), + ], + default="pending", + max_length=30, + ), ), ] diff --git a/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py b/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py index 127aa5e69..11678efbc 100644 --- a/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py +++ b/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py @@ -1,22 +1,23 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import os -from django.db import migrations, models +from django.db import migrations from funkwhale_api.music.utils import guess_mimetype def populate_mimetype(apps, schema_editor): TrackFile = apps.get_model("music", "TrackFile") - for tf in TrackFile.objects.filter(audio_file__isnull=False, mimetype__isnull=True).only('audio_file'): + for tf in TrackFile.objects.filter( + audio_file__isnull=False, mimetype__isnull=True + ).only("audio_file"): try: tf.mimetype = guess_mimetype(tf.audio_file) except Exception as e: - print('Error on track file {}: {}'.format(tf.pk, e)) + print("Error on track file {}: {}".format(tf.pk, e)) continue - print('Track file {}: {}'.format(tf.pk, tf.mimetype)) - tf.save(update_fields=['mimetype']) + print("Track file {}: {}".format(tf.pk, tf.mimetype)) + tf.save(update_fields=["mimetype"]) def rewind(apps, schema_editor): @@ -25,10 +26,6 @@ def rewind(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('music', '0018_auto_20180218_1554'), - ] + dependencies = [("music", "0018_auto_20180218_1554")] - operations = [ - migrations.RunPython(populate_mimetype, rewind), - ] + operations = [migrations.RunPython(populate_mimetype, rewind)] diff --git a/api/funkwhale_api/music/migrations/0020_importbatch_status.py b/api/funkwhale_api/music/migrations/0020_importbatch_status.py index 265d1ba5d..e02aa0859 100644 --- a/api/funkwhale_api/music/migrations/0020_importbatch_status.py +++ b/api/funkwhale_api/music/migrations/0020_importbatch_status.py @@ -5,14 +5,21 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0019_populate_mimetypes'), - ] + dependencies = [("music", "0019_populate_mimetypes")] operations = [ migrations.AddField( - model_name='importbatch', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished'), ('errored', 'Errored'), ('skipped', 'Skipped')], default='pending', max_length=30), - ), + model_name="importbatch", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("finished", "Finished"), + ("errored", "Errored"), + ("skipped", "Skipped"), + ], + default="pending", + max_length=30, + ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0021_populate_batch_status.py b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py index 061d649b0..065384a97 100644 --- a/api/funkwhale_api/music/migrations/0021_populate_batch_status.py +++ b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import os -from django.db import migrations, models +from django.db import migrations def populate_status(apps, schema_editor): from funkwhale_api.music.utils import compute_status + ImportBatch = apps.get_model("music", "ImportBatch") - for ib in ImportBatch.objects.prefetch_related('jobs'): + for ib in ImportBatch.objects.prefetch_related("jobs"): ib.status = compute_status(ib.jobs.all()) - ib.save(update_fields=['status']) + ib.save(update_fields=["status"]) def rewind(apps, schema_editor): @@ -20,10 +20,6 @@ def rewind(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('music', '0020_importbatch_status'), - ] + dependencies = [("music", "0020_importbatch_status")] - operations = [ - migrations.RunPython(populate_status, rewind), - ] + operations = [migrations.RunPython(populate_status, rewind)] diff --git a/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py index d9f6f01d9..89fca02d6 100644 --- a/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py +++ b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py @@ -6,15 +6,18 @@ import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('requests', '__first__'), - ('music', '0021_populate_batch_status'), - ] + dependencies = [("requests", "__first__"), ("music", "0021_populate_batch_status")] operations = [ migrations.AddField( - model_name='importbatch', - name='import_request', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='import_batches', to='requests.ImportRequest'), - ), + model_name="importbatch", + name="import_request", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="import_batches", + to="requests.ImportRequest", + ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0023_auto_20180407_1010.py b/api/funkwhale_api/music/migrations/0023_auto_20180407_1010.py index ed7404ac4..8c6537d85 100644 --- a/api/funkwhale_api/music/migrations/0023_auto_20180407_1010.py +++ b/api/funkwhale_api/music/migrations/0023_auto_20180407_1010.py @@ -9,79 +9,105 @@ import django.utils.timezone class Migration(migrations.Migration): dependencies = [ - ('federation', '0003_auto_20180407_1010'), - ('music', '0022_importbatch_import_request'), + ("federation", "0003_auto_20180407_1010"), + ("music", "0022_importbatch_import_request"), ] operations = [ migrations.AddField( - model_name='album', - name='uuid', + model_name="album", + name="uuid", field=models.UUIDField(db_index=True, null=True, unique=True), ), migrations.AddField( - model_name='artist', - name='uuid', + model_name="artist", + name="uuid", field=models.UUIDField(db_index=True, null=True, unique=True), ), migrations.AddField( - model_name='importbatch', - name='uuid', + model_name="importbatch", + name="uuid", field=models.UUIDField(db_index=True, null=True, unique=True), ), migrations.AddField( - model_name='importjob', - name='library_track', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='import_jobs', to='federation.LibraryTrack'), + model_name="importjob", + name="library_track", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="import_jobs", + to="federation.LibraryTrack", + ), ), migrations.AddField( - model_name='importjob', - name='uuid', + model_name="importjob", + name="uuid", field=models.UUIDField(db_index=True, null=True, unique=True), ), migrations.AddField( - model_name='lyrics', - name='uuid', + model_name="lyrics", + name="uuid", field=models.UUIDField(db_index=True, null=True, unique=True), ), migrations.AddField( - model_name='track', - name='uuid', + model_name="track", + name="uuid", field=models.UUIDField(db_index=True, null=True, unique=True), ), migrations.AddField( - model_name='trackfile', - name='creation_date', + model_name="trackfile", + name="creation_date", field=models.DateTimeField(default=django.utils.timezone.now), ), migrations.AddField( - model_name='trackfile', - name='library_track', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='local_track_file', to='federation.LibraryTrack'), + model_name="trackfile", + name="library_track", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="local_track_file", + to="federation.LibraryTrack", + ), ), migrations.AddField( - model_name='trackfile', - name='modification_date', + model_name="trackfile", + name="modification_date", field=models.DateTimeField(auto_now=True), ), migrations.AddField( - model_name='trackfile', - name='uuid', + model_name="trackfile", + name="uuid", field=models.UUIDField(db_index=True, null=True, unique=True), ), migrations.AddField( - model_name='work', - name='uuid', + model_name="work", + name="uuid", field=models.UUIDField(db_index=True, null=True, unique=True), ), migrations.AlterField( - model_name='importbatch', - name='source', - field=models.CharField(choices=[('api', 'api'), ('shell', 'shell'), ('federation', 'federation')], default='api', max_length=30), + model_name="importbatch", + name="source", + field=models.CharField( + choices=[ + ("api", "api"), + ("shell", "shell"), + ("federation", "federation"), + ], + default="api", + max_length=30, + ), ), migrations.AlterField( - model_name='importbatch', - name='submitted_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='imports', to=settings.AUTH_USER_MODEL), + model_name="importbatch", + name="submitted_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="imports", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/api/funkwhale_api/music/migrations/0024_populate_uuid.py b/api/funkwhale_api/music/migrations/0024_populate_uuid.py index 10c78a3db..63ab63862 100644 --- a/api/funkwhale_api/music/migrations/0024_populate_uuid.py +++ b/api/funkwhale_api/music/migrations/0024_populate_uuid.py @@ -1,28 +1,27 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import os import uuid from django.db import migrations, models def populate_uuids(apps, schema_editor): models = [ - 'Album', - 'Artist', - 'Importbatch', - 'Importjob', - 'Lyrics', - 'Track', - 'Trackfile', - 'Work', + "Album", + "Artist", + "Importbatch", + "Importjob", + "Lyrics", + "Track", + "Trackfile", + "Work", ] for m in models: - kls = apps.get_model('music', m) - qs = kls.objects.filter(uuid__isnull=True).only('id') - print('Setting uuids for {} ({} objects)'.format(m, len(qs))) + kls = apps.get_model("music", m) + qs = kls.objects.filter(uuid__isnull=True).only("id") + print("Setting uuids for {} ({} objects)".format(m, len(qs))) for o in qs: o.uuid = uuid.uuid4() - o.save(update_fields=['uuid']) + o.save(update_fields=["uuid"]) def rewind(apps, schema_editor): @@ -31,50 +30,48 @@ def rewind(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('music', '0023_auto_20180407_1010'), - ] + dependencies = [("music", "0023_auto_20180407_1010")] operations = [ migrations.RunPython(populate_uuids, rewind), migrations.AlterField( - model_name='album', - name='uuid', + model_name="album", + name="uuid", field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), ), migrations.AlterField( - model_name='artist', - name='uuid', + model_name="artist", + name="uuid", field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), ), migrations.AlterField( - model_name='importbatch', - name='uuid', + model_name="importbatch", + name="uuid", field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), ), migrations.AlterField( - model_name='importjob', - name='uuid', + model_name="importjob", + name="uuid", field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), ), migrations.AlterField( - model_name='lyrics', - name='uuid', + model_name="lyrics", + name="uuid", field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), ), migrations.AlterField( - model_name='track', - name='uuid', + model_name="track", + name="uuid", field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), ), migrations.AlterField( - model_name='trackfile', - name='uuid', + model_name="trackfile", + name="uuid", field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), ), migrations.AlterField( - model_name='work', - name='uuid', + model_name="work", + name="uuid", field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), ), ] diff --git a/api/funkwhale_api/music/migrations/0025_auto_20180419_2023.py b/api/funkwhale_api/music/migrations/0025_auto_20180419_2023.py index 6b0230d50..be685f1fe 100644 --- a/api/funkwhale_api/music/migrations/0025_auto_20180419_2023.py +++ b/api/funkwhale_api/music/migrations/0025_auto_20180419_2023.py @@ -5,14 +5,12 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0024_populate_uuid'), - ] + dependencies = [("music", "0024_populate_uuid")] operations = [ migrations.AlterField( - model_name='trackfile', - name='source', + model_name="trackfile", + name="source", field=models.URLField(blank=True, max_length=500, null=True), - ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0026_trackfile_accessed_date.py b/api/funkwhale_api/music/migrations/0026_trackfile_accessed_date.py index 1d5327d93..f7f46f35a 100644 --- a/api/funkwhale_api/music/migrations/0026_trackfile_accessed_date.py +++ b/api/funkwhale_api/music/migrations/0026_trackfile_accessed_date.py @@ -5,14 +5,12 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('music', '0025_auto_20180419_2023'), - ] + dependencies = [("music", "0025_auto_20180419_2023")] operations = [ migrations.AddField( - model_name='trackfile', - name='accessed_date', + model_name="trackfile", + name="accessed_date", field=models.DateTimeField(blank=True, null=True), - ), + ) ] diff --git a/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py b/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py index 835e115a6..1e3949da4 100644 --- a/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py +++ b/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py @@ -6,24 +6,28 @@ import taggit.managers class Migration(migrations.Migration): - dependencies = [ - ('music', '0026_trackfile_accessed_date'), - ] + dependencies = [("music", "0026_trackfile_accessed_date")] operations = [ migrations.AddField( - model_name='trackfile', - name='bitrate', + model_name="trackfile", + name="bitrate", field=models.IntegerField(blank=True, null=True), ), migrations.AddField( - model_name='trackfile', - name='size', + model_name="trackfile", + name="size", field=models.IntegerField(blank=True, null=True), ), migrations.AlterField( - model_name='track', - name='tags', - field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + model_name="track", + name="tags", + field=taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), ), ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index bf3f9e12c..8b638ce7d 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1,43 +1,38 @@ -import os -import io -import arrow import datetime -import tempfile +import os import shutil -import markdown +import tempfile import uuid +import arrow +import markdown from django.conf import settings -from django.db import models -from django.core.files.base import ContentFile from django.core.files import File +from django.core.files.base import ContentFile +from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.urls import reverse from django.utils import timezone - from taggit.managers import TaggableManager from versatileimagefield.fields import VersatileImageField -from funkwhale_api import downloader -from funkwhale_api import musicbrainz +from funkwhale_api import downloader, musicbrainz from funkwhale_api.federation import utils as federation_utils -from . import importers -from . import metadata -from . import utils + +from . import importers, metadata, utils class APIModelMixin(models.Model): mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True) - uuid = models.UUIDField( - unique=True, db_index=True, default=uuid.uuid4) + uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) api_includes = [] creation_date = models.DateTimeField(default=timezone.now) import_hooks = [] class Meta: abstract = True - ordering = ['-creation_date'] + ordering = ["-creation_date"] @classmethod def get_or_create_from_api(cls, mbid): @@ -47,14 +42,20 @@ class APIModelMixin(models.Model): return cls.create_from_api(id=mbid), True def get_api_data(self): - return self.__class__.api.get(id=self.mbid, includes=self.api_includes)[self.musicbrainz_model] + return self.__class__.api.get(id=self.mbid, includes=self.api_includes)[ + self.musicbrainz_model + ] @classmethod def create_from_api(cls, **kwargs): - if kwargs.get('id'): - raw_data = cls.api.get(id=kwargs['id'], includes=cls.api_includes)[cls.musicbrainz_model] + if kwargs.get("id"): + raw_data = cls.api.get(id=kwargs["id"], includes=cls.api_includes)[ + cls.musicbrainz_model + ] else: - raw_data = cls.api.search(**kwargs)['{0}-list'.format(cls.musicbrainz_model)][0] + raw_data = cls.api.search(**kwargs)[ + "{0}-list".format(cls.musicbrainz_model) + ][0] cleaned_data = cls.clean_musicbrainz_data(raw_data) return importers.load(cls, cleaned_data, raw_data, cls.import_hooks) @@ -66,39 +67,35 @@ class APIModelMixin(models.Model): try: cleaned_key, cleaned_value = mapping.from_musicbrainz(key, value) cleaned_data[cleaned_key] = cleaned_value - except KeyError as e: + except KeyError: pass return cleaned_data @property def musicbrainz_url(self): if self.mbid: - return 'https://musicbrainz.org/{}/{}'.format( - self.musicbrainz_model, self.mbid) + return "https://musicbrainz.org/{}/{}".format( + self.musicbrainz_model, self.mbid + ) class ArtistQuerySet(models.QuerySet): def with_albums_count(self): - return self.annotate(_albums_count=models.Count('albums')) + return self.annotate(_albums_count=models.Count("albums")) def with_albums(self): return self.prefetch_related( - models.Prefetch( - 'albums', queryset=Album.objects.with_tracks_count()) + models.Prefetch("albums", queryset=Album.objects.with_tracks_count()) ) class Artist(APIModelMixin): name = models.CharField(max_length=255) - musicbrainz_model = 'artist' + musicbrainz_model = "artist" musicbrainz_mapping = { - 'mbid': { - 'musicbrainz_field_name': 'id' - }, - 'name': { - 'musicbrainz_field_name': 'name' - } + "mbid": {"musicbrainz_field_name": "id"}, + "name": {"musicbrainz_field_name": "name"}, } api = musicbrainz.api.artists objects = ArtistQuerySet.as_manager() @@ -116,14 +113,12 @@ class Artist(APIModelMixin): @classmethod def get_or_create_from_name(cls, name, **kwargs): - kwargs.update({'name': name}) - return cls.objects.get_or_create( - name__iexact=name, - defaults=kwargs) + kwargs.update({"name": name}) + return cls.objects.get_or_create(name__iexact=name, defaults=kwargs) def import_artist(v): - a = Artist.get_or_create_from_api(mbid=v[0]['artist']['id'])[0] + a = Artist.get_or_create_from_api(mbid=v[0]["artist"]["id"])[0] return a @@ -135,78 +130,62 @@ def parse_date(v): def import_tracks(instance, cleaned_data, raw_data): - for track_data in raw_data['medium-list'][0]['track-list']: - track_cleaned_data = Track.clean_musicbrainz_data(track_data['recording']) - track_cleaned_data['album'] = instance - track_cleaned_data['position'] = int(track_data['position']) - track = importers.load(Track, track_cleaned_data, track_data, Track.import_hooks) + for track_data in raw_data["medium-list"][0]["track-list"]: + track_cleaned_data = Track.clean_musicbrainz_data(track_data["recording"]) + track_cleaned_data["album"] = instance + track_cleaned_data["position"] = int(track_data["position"]) + importers.load(Track, track_cleaned_data, track_data, Track.import_hooks) class AlbumQuerySet(models.QuerySet): def with_tracks_count(self): - return self.annotate(_tracks_count=models.Count('tracks')) + return self.annotate(_tracks_count=models.Count("tracks")) class Album(APIModelMixin): title = models.CharField(max_length=255) - artist = models.ForeignKey( - Artist, related_name='albums', on_delete=models.CASCADE) + artist = models.ForeignKey(Artist, related_name="albums", on_delete=models.CASCADE) release_date = models.DateField(null=True) release_group_id = models.UUIDField(null=True, blank=True) - cover = VersatileImageField(upload_to='albums/covers/%Y/%m/%d', null=True, blank=True) - TYPE_CHOICES = ( - ('album', 'Album'), + cover = VersatileImageField( + upload_to="albums/covers/%Y/%m/%d", null=True, blank=True ) - type = models.CharField(choices=TYPE_CHOICES, max_length=30, default='album') + TYPE_CHOICES = (("album", "Album"),) + type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album") - api_includes = ['artist-credits', 'recordings', 'media', 'release-groups'] + api_includes = ["artist-credits", "recordings", "media", "release-groups"] api = musicbrainz.api.releases - musicbrainz_model = 'release' + musicbrainz_model = "release" musicbrainz_mapping = { - 'mbid': { - 'musicbrainz_field_name': 'id', + "mbid": {"musicbrainz_field_name": "id"}, + "position": { + "musicbrainz_field_name": "release-list", + "converter": lambda v: int(v[0]["medium-list"][0]["position"]), }, - 'position': { - 'musicbrainz_field_name': 'release-list', - 'converter': lambda v: int(v[0]['medium-list'][0]['position']), + "release_group_id": { + "musicbrainz_field_name": "release-group", + "converter": lambda v: v["id"], }, - 'release_group_id': { - 'musicbrainz_field_name': 'release-group', - 'converter': lambda v: v['id'], + "title": {"musicbrainz_field_name": "title"}, + "release_date": {"musicbrainz_field_name": "date", "converter": parse_date}, + "type": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()}, + "artist": { + "musicbrainz_field_name": "artist-credit", + "converter": import_artist, }, - 'title': { - 'musicbrainz_field_name': 'title', - }, - 'release_date': { - 'musicbrainz_field_name': 'date', - 'converter': parse_date, - - }, - 'type': { - 'musicbrainz_field_name': 'type', - 'converter': lambda v: v.lower(), - }, - 'artist': { - 'musicbrainz_field_name': 'artist-credit', - 'converter': import_artist, - } } objects = AlbumQuerySet.as_manager() def get_image(self, data=None): if data: - f = ContentFile(data['content']) - extensions = { - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/gif': 'gif', - } - extension = extensions.get(data['mimetype'], 'jpg') - self.cover.save('{}.{}'.format(self.uuid, extension), f) + f = ContentFile(data["content"]) + extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"} + extension = extensions.get(data["mimetype"], "jpg") + self.cover.save("{}.{}".format(self.uuid, extension), f) else: - image_data = musicbrainz.api.images.get_front(str(self.mbid)) + image_data = musicbrainz.api.images.get_front(str(self.mbid)) f = ContentFile(image_data) - self.cover.save('{0}.jpg'.format(self.mbid), f) + self.cover.save("{0}.jpg".format(self.mbid), f) return self.cover.file def __str__(self): @@ -222,35 +201,30 @@ class Album(APIModelMixin): @classmethod def get_or_create_from_title(cls, title, **kwargs): - kwargs.update({'title': title}) - return cls.objects.get_or_create( - title__iexact=title, - defaults=kwargs) + kwargs.update({"title": title}) + return cls.objects.get_or_create(title__iexact=title, defaults=kwargs) def import_tags(instance, cleaned_data, raw_data): MINIMUM_COUNT = 2 tags_to_add = [] - for tag_data in raw_data.get('tag-list', []): + for tag_data in raw_data.get("tag-list", []): try: - if int(tag_data['count']) < MINIMUM_COUNT: + if int(tag_data["count"]) < MINIMUM_COUNT: continue except ValueError: continue - tags_to_add.append(tag_data['name']) + tags_to_add.append(tag_data["name"]) instance.tags.add(*tags_to_add) def import_album(v): - a = Album.get_or_create_from_api(mbid=v[0]['id'])[0] + a = Album.get_or_create_from_api(mbid=v[0]["id"])[0] return a def link_recordings(instance, cleaned_data, raw_data): - tracks = [ - r['target'] - for r in raw_data['recording-relation-list'] - ] + tracks = [r["target"] for r in raw_data["recording-relation-list"]] Track.objects.filter(mbid__in=tracks).update(work=instance) @@ -258,9 +232,9 @@ def import_lyrics(instance, cleaned_data, raw_data): try: url = [ url_data - for url_data in raw_data['url-relation-list'] - if url_data['type'] == 'lyrics' - ][0]['target'] + for url_data in raw_data["url-relation-list"] + if url_data["type"] == "lyrics" + ][0]["target"] except (IndexError, KeyError): return l, _ = Lyrics.objects.get_or_create(work=instance, url=url) @@ -274,47 +248,31 @@ class Work(APIModelMixin): title = models.CharField(max_length=255) api = musicbrainz.api.works - api_includes = ['url-rels', 'recording-rels'] - musicbrainz_model = 'work' + api_includes = ["url-rels", "recording-rels"] + musicbrainz_model = "work" musicbrainz_mapping = { - 'mbid': { - 'musicbrainz_field_name': 'id' - }, - 'title': { - 'musicbrainz_field_name': 'title' - }, - 'language': { - 'musicbrainz_field_name': 'language', - }, - 'nature': { - 'musicbrainz_field_name': 'type', - 'converter': lambda v: v.lower(), - }, + "mbid": {"musicbrainz_field_name": "id"}, + "title": {"musicbrainz_field_name": "title"}, + "language": {"musicbrainz_field_name": "language"}, + "nature": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()}, } - import_hooks = [ - import_lyrics, - link_recordings - ] + import_hooks = [import_lyrics, link_recordings] def fetch_lyrics(self): - l = self.lyrics.first() - if l: - return l - data = self.api.get(self.mbid, includes=['url-rels'])['work'] - l = import_lyrics(self, {}, data) + lyric = self.lyrics.first() + if lyric: + return lyric + data = self.api.get(self.mbid, includes=["url-rels"])["work"] + lyric = import_lyrics(self, {}, data) - return l + return lyric class Lyrics(models.Model): - uuid = models.UUIDField( - unique=True, db_index=True, default=uuid.uuid4) + uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) work = models.ForeignKey( - Work, - related_name='lyrics', - null=True, - blank=True, - on_delete=models.CASCADE) + Work, related_name="lyrics", null=True, blank=True, on_delete=models.CASCADE + ) url = models.URLField(unique=True) content = models.TextField(null=True, blank=True) @@ -324,67 +282,55 @@ class Lyrics(models.Model): self.content, safe_mode=True, enable_attributes=False, - extensions=['markdown.extensions.nl2br']) + extensions=["markdown.extensions.nl2br"], + ) class TrackQuerySet(models.QuerySet): def for_nested_serialization(self): - return (self.select_related() - .select_related('album__artist', 'artist') - .prefetch_related('files')) + return ( + self.select_related() + .select_related("album__artist", "artist") + .prefetch_related("files") + ) def get_artist(release_list): return Artist.get_or_create_from_api( - mbid=release_list[0]['artist-credits'][0]['artists']['id'])[0] + mbid=release_list[0]["artist-credits"][0]["artists"]["id"] + )[0] class Track(APIModelMixin): title = models.CharField(max_length=255) - artist = models.ForeignKey( - Artist, related_name='tracks', on_delete=models.CASCADE) + artist = models.ForeignKey(Artist, related_name="tracks", on_delete=models.CASCADE) position = models.PositiveIntegerField(null=True, blank=True) album = models.ForeignKey( - Album, - related_name='tracks', - null=True, - blank=True, - on_delete=models.CASCADE) + Album, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE + ) work = models.ForeignKey( - Work, - related_name='tracks', - null=True, - blank=True, - on_delete=models.CASCADE) + Work, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE + ) - musicbrainz_model = 'recording' + musicbrainz_model = "recording" api = musicbrainz.api.recordings - api_includes = ['artist-credits', 'releases', 'media', 'tags', 'work-rels'] + api_includes = ["artist-credits", "releases", "media", "tags", "work-rels"] musicbrainz_mapping = { - 'mbid': { - 'musicbrainz_field_name': 'id' - }, - 'title': { - 'musicbrainz_field_name': 'title' - }, - 'artist': { + "mbid": {"musicbrainz_field_name": "id"}, + "title": {"musicbrainz_field_name": "title"}, + "artist": { # we use the artist from the release to avoid #237 - 'musicbrainz_field_name': 'release-list', - 'converter': get_artist, - }, - 'album': { - 'musicbrainz_field_name': 'release-list', - 'converter': import_album, + "musicbrainz_field_name": "release-list", + "converter": get_artist, }, + "album": {"musicbrainz_field_name": "release-list", "converter": import_album}, } - import_hooks = [ - import_tags - ] + import_hooks = [import_tags] objects = TrackQuerySet.as_manager() tags = TaggableManager(blank=True) class Meta: - ordering = ['album', 'position'] + ordering = ["album", "position"] def __str__(self): return self.title @@ -399,43 +345,33 @@ class Track(APIModelMixin): def get_work(self): if self.work: return self.work - data = self.api.get(self.mbid, includes=['work-rels']) + data = self.api.get(self.mbid, includes=["work-rels"]) try: - work_data = data['recording']['work-relation-list'][0]['work'] + work_data = data["recording"]["work-relation-list"][0]["work"] except (IndexError, KeyError): return - work, _ = Work.get_or_create_from_api(mbid=work_data['id']) + work, _ = Work.get_or_create_from_api(mbid=work_data["id"]) return work def get_lyrics_url(self): - return reverse('api:v1:tracks-lyrics', kwargs={'pk': self.pk}) + return reverse("api:v1:tracks-lyrics", kwargs={"pk": self.pk}) @property def full_name(self): try: - return '{} - {} - {}'.format( - self.artist.name, - self.album.title, - self.title, - ) + return "{} - {} - {}".format(self.artist.name, self.album.title, self.title) except AttributeError: - return '{} - {}'.format( - self.artist.name, - self.title, - ) + return "{} - {}".format(self.artist.name, self.title) def get_activity_url(self): if self.mbid: - return 'https://musicbrainz.org/recording/{}'.format( - self.mbid) - return settings.FUNKWHALE_URL + '/tracks/{}'.format(self.pk) + return "https://musicbrainz.org/recording/{}".format(self.mbid) + return settings.FUNKWHALE_URL + "/tracks/{}".format(self.pk) @classmethod def get_or_create_from_title(cls, title, **kwargs): - kwargs.update({'title': title}) - return cls.objects.get_or_create( - title__iexact=title, - defaults=kwargs) + kwargs.update({"title": title}) + return cls.objects.get_or_create(title__iexact=title, defaults=kwargs) @classmethod def get_or_create_from_release(cls, release_mbid, mbid): @@ -448,35 +384,32 @@ class Track(APIModelMixin): album = Album.get_or_create_from_api(release_mbid)[0] data = musicbrainz.client.api.releases.get( - str(album.mbid), includes=Album.api_includes) - tracks = [ - t - for m in data['release']['medium-list'] - for t in m['track-list'] - ] + str(album.mbid), includes=Album.api_includes + ) + tracks = [t for m in data["release"]["medium-list"] for t in m["track-list"]] track_data = None for track in tracks: - if track['recording']['id'] == mbid: + if track["recording"]["id"] == mbid: track_data = track break if not track_data: - raise ValueError('No track found matching this ID') + raise ValueError("No track found matching this ID") return cls.objects.update_or_create( mbid=mbid, defaults={ - 'position': int(track['position']), - 'title': track['recording']['title'], - 'album': album, - 'artist': album.artist, - } + "position": int(track["position"]), + "title": track["recording"]["title"], + "album": album, + "artist": album.artist, + }, ) + + class TrackFile(models.Model): - uuid = models.UUIDField( - unique=True, db_index=True, default=uuid.uuid4) - track = models.ForeignKey( - Track, related_name='files', on_delete=models.CASCADE) - audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255) + uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) + track = models.ForeignKey(Track, related_name="files", on_delete=models.CASCADE) + audio_file = models.FileField(upload_to="tracks/%Y/%m/%d", max_length=255) source = models.URLField(null=True, blank=True, max_length=500) creation_date = models.DateTimeField(default=timezone.now) modification_date = models.DateTimeField(auto_now=True) @@ -488,8 +421,8 @@ class TrackFile(models.Model): mimetype = models.CharField(null=True, blank=True, max_length=200) library_track = models.OneToOneField( - 'federation.LibraryTrack', - related_name='local_track_file', + "federation.LibraryTrack", + related_name="local_track_file", on_delete=models.CASCADE, null=True, blank=True, @@ -499,45 +432,38 @@ class TrackFile(models.Model): # import the track file, since there is not any # we create a tmp dir for the download tmp_dir = tempfile.mkdtemp() - data = downloader.download( - self.source, - target_directory=tmp_dir) - self.duration = data.get('duration', None) + data = downloader.download(self.source, target_directory=tmp_dir) + self.duration = data.get("duration", None) self.audio_file.save( - os.path.basename(data['audio_file_path']), - File(open(data['audio_file_path'], 'rb')) + os.path.basename(data["audio_file_path"]), + File(open(data["audio_file_path"], "rb")), ) shutil.rmtree(tmp_dir) return self.audio_file def get_federation_url(self): - return federation_utils.full_url( - '/federation/music/file/{}'.format(self.uuid) - ) + return federation_utils.full_url("/federation/music/file/{}".format(self.uuid)) @property def path(self): - return reverse( - 'api:v1:trackfiles-serve', kwargs={'pk': self.pk}) + return reverse("api:v1:trackfiles-serve", kwargs={"pk": self.pk}) @property def filename(self): - return '{}.{}'.format( - self.track.full_name, - self.extension) + return "{}.{}".format(self.track.full_name, self.extension) @property def extension(self): if not self.audio_file: return - return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1) + return os.path.splitext(self.audio_file.name)[-1].replace(".", "", 1) def get_file_size(self): if self.audio_file: return self.audio_file.size - if self.source.startswith('file://'): - return os.path.getsize(self.source.replace('file://', '', 1)) + if self.source.startswith("file://"): + return os.path.getsize(self.source.replace("file://", "", 1)) if self.library_track and self.library_track.audio_file: return self.library_track.audio_file.size @@ -545,8 +471,8 @@ class TrackFile(models.Model): def get_audio_file(self): if self.audio_file: return self.audio_file.open() - if self.source.startswith('file://'): - return open(self.source.replace('file://', '', 1), 'rb') + if self.source.startswith("file://"): + return open(self.source.replace("file://", "", 1), "rb") if self.library_track and self.library_track.audio_file: return self.library_track.audio_file.open() @@ -557,15 +483,15 @@ class TrackFile(models.Model): audio_data = utils.get_audio_file_data(f) if not audio_data: return - self.duration = int(audio_data['length']) - self.bitrate = audio_data['bitrate'] + self.duration = int(audio_data["length"]) + self.bitrate = audio_data["bitrate"] self.size = self.get_file_size() else: lt = self.library_track if lt: - self.duration = lt.get_metadata('length') - self.size = lt.get_metadata('size') - self.bitrate = lt.get_metadata('bitrate') + self.duration = lt.get_metadata("length") + self.size = lt.get_metadata("size") + self.bitrate = lt.get_metadata("bitrate") def save(self, **kwargs): if not self.mimetype and self.audio_file: @@ -580,41 +506,44 @@ class TrackFile(models.Model): IMPORT_STATUS_CHOICES = ( - ('pending', 'Pending'), - ('finished', 'Finished'), - ('errored', 'Errored'), - ('skipped', 'Skipped'), + ("pending", "Pending"), + ("finished", "Finished"), + ("errored", "Errored"), + ("skipped", "Skipped"), ) class ImportBatch(models.Model): - uuid = models.UUIDField( - unique=True, db_index=True, default=uuid.uuid4) + uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) IMPORT_BATCH_SOURCES = [ - ('api', 'api'), - ('shell', 'shell'), - ('federation', 'federation'), + ("api", "api"), + ("shell", "shell"), + ("federation", "federation"), ] source = models.CharField( - max_length=30, default='api', choices=IMPORT_BATCH_SOURCES) + max_length=30, default="api", choices=IMPORT_BATCH_SOURCES + ) creation_date = models.DateTimeField(default=timezone.now) submitted_by = models.ForeignKey( - 'users.User', - related_name='imports', + "users.User", + related_name="imports", null=True, blank=True, - on_delete=models.CASCADE) + on_delete=models.CASCADE, + ) status = models.CharField( - choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30) + choices=IMPORT_STATUS_CHOICES, default="pending", max_length=30 + ) import_request = models.ForeignKey( - 'requests.ImportRequest', - related_name='import_batches', + "requests.ImportRequest", + related_name="import_batches", null=True, blank=True, - on_delete=models.CASCADE) + on_delete=models.CASCADE, + ) class Meta: - ordering = ['-creation_date'] + ordering = ["-creation_date"] def __str__(self): return str(self.pk) @@ -624,46 +553,46 @@ class ImportBatch(models.Model): self.status = utils.compute_status(self.jobs.all()) if self.status == old_status: return - self.save(update_fields=['status']) - if self.status != old_status and self.status == 'finished': + self.save(update_fields=["status"]) + if self.status != old_status and self.status == "finished": from . import tasks + tasks.import_batch_notify_followers.delay(import_batch_id=self.pk) def get_federation_url(self): return federation_utils.full_url( - '/federation/music/import/batch/{}'.format(self.uuid) + "/federation/music/import/batch/{}".format(self.uuid) ) class ImportJob(models.Model): - uuid = models.UUIDField( - unique=True, db_index=True, default=uuid.uuid4) + uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) batch = models.ForeignKey( - ImportBatch, related_name='jobs', on_delete=models.CASCADE) + ImportBatch, related_name="jobs", on_delete=models.CASCADE + ) track_file = models.ForeignKey( - TrackFile, - related_name='jobs', - null=True, - blank=True, - on_delete=models.CASCADE) + TrackFile, related_name="jobs", null=True, blank=True, on_delete=models.CASCADE + ) source = models.CharField(max_length=500) mbid = models.UUIDField(editable=False, null=True, blank=True) status = models.CharField( - choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30) + choices=IMPORT_STATUS_CHOICES, default="pending", max_length=30 + ) audio_file = models.FileField( - upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True) + upload_to="imports/%Y/%m/%d", max_length=255, null=True, blank=True + ) library_track = models.ForeignKey( - 'federation.LibraryTrack', - related_name='import_jobs', + "federation.LibraryTrack", + related_name="import_jobs", on_delete=models.SET_NULL, null=True, - blank=True + blank=True, ) class Meta: - ordering = ('id', ) + ordering = ("id",) @receiver(post_save, sender=ImportJob) @@ -673,22 +602,22 @@ def update_batch_status(sender, instance, **kwargs): @receiver(post_save, sender=ImportBatch) def update_request_status(sender, instance, created, **kwargs): - update_fields = kwargs.get('update_fields', []) or [] + update_fields = kwargs.get("update_fields", []) or [] if not instance.import_request: return - if not created and not 'status' in update_fields: + if not created and "status" not in update_fields: return r_status = instance.import_request.status status = instance.status - if status == 'pending' and r_status == 'pending': + if status == "pending" and r_status == "pending": # let's mark the request as accepted since we started an import - instance.import_request.status = 'accepted' - return instance.import_request.save(update_fields=['status']) + instance.import_request.status = "accepted" + return instance.import_request.save(update_fields=["status"]) - if status == 'finished' and r_status == 'accepted': + if status == "finished" and r_status == "accepted": # let's mark the request as imported since the import is over - instance.import_request.status = 'imported' - return instance.import_request.save(update_fields=['status']) + instance.import_request.status = "imported" + return instance.import_request.save(update_fields=["status"]) diff --git a/api/funkwhale_api/music/permissions.py b/api/funkwhale_api/music/permissions.py index d31e1c5d5..dc589b5dd 100644 --- a/api/funkwhale_api/music/permissions.py +++ b/api/funkwhale_api/music/permissions.py @@ -1,29 +1,24 @@ -from django.conf import settings from rest_framework.permissions import BasePermission from funkwhale_api.common import preferences -from funkwhale_api.federation import actors -from funkwhale_api.federation import models +from funkwhale_api.federation import actors, models class Listen(BasePermission): - def has_permission(self, request, view): - if not preferences.get('common__api_authentication_required'): + if not preferences.get("common__api_authentication_required"): return True - user = getattr(request, 'user', None) + user = getattr(request, "user", None) if user and user.is_authenticated: return True - actor = getattr(request, 'actor', None) + actor = getattr(request, "actor", None) if actor is None: return False - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() return models.Follow.objects.filter( - target=library, - actor=actor, - approved=True + target=library, actor=actor, approved=True ).exists() diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index b72bb8c4a..c34970d0b 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -1,16 +1,11 @@ -from django.db import transaction from django.db.models import Q from rest_framework import serializers from taggit.models import Tag from funkwhale_api.activity import serializers as activity_serializers -from funkwhale_api.federation import utils as federation_utils -from funkwhale_api.federation.models import LibraryTrack -from funkwhale_api.federation.serializers import AP_CONTEXT from funkwhale_api.users.serializers import UserBasicSerializer -from . import models -from . import tasks +from . import models, tasks class ArtistAlbumSerializer(serializers.ModelSerializer): @@ -19,14 +14,14 @@ class ArtistAlbumSerializer(serializers.ModelSerializer): class Meta: model = models.Album fields = ( - 'id', - 'mbid', - 'title', - 'artist', - 'release_date', - 'cover', - 'creation_date', - 'tracks_count', + "id", + "mbid", + "title", + "artist", + "release_date", + "cover", + "creation_date", + "tracks_count", ) def get_tracks_count(self, o): @@ -38,13 +33,7 @@ class ArtistWithAlbumsSerializer(serializers.ModelSerializer): class Meta: model = models.Artist - fields = ( - 'id', - 'mbid', - 'name', - 'creation_date', - 'albums', - ) + fields = ("id", "mbid", "name", "creation_date", "albums") class TrackFileSerializer(serializers.ModelSerializer): @@ -53,23 +42,18 @@ class TrackFileSerializer(serializers.ModelSerializer): class Meta: model = models.TrackFile fields = ( - 'id', - 'path', - 'source', - 'filename', - 'mimetype', - 'track', - 'duration', - 'mimetype', - 'bitrate', - 'size', + "id", + "path", + "source", + "filename", + "mimetype", + "track", + "duration", + "mimetype", + "bitrate", + "size", ) - read_only_fields = [ - 'duration', - 'mimetype', - 'bitrate', - 'size', - ] + read_only_fields = ["duration", "mimetype", "bitrate", "size"] def get_path(self, o): url = o.path @@ -82,26 +66,21 @@ class AlbumTrackSerializer(serializers.ModelSerializer): class Meta: model = models.Track fields = ( - 'id', - 'mbid', - 'title', - 'album', - 'artist', - 'creation_date', - 'files', - 'position', + "id", + "mbid", + "title", + "album", + "artist", + "creation_date", + "files", + "position", ) class ArtistSimpleSerializer(serializers.ModelSerializer): class Meta: model = models.Artist - fields = ( - 'id', - 'mbid', - 'name', - 'creation_date', - ) + fields = ("id", "mbid", "name", "creation_date") class AlbumSerializer(serializers.ModelSerializer): @@ -111,20 +90,20 @@ class AlbumSerializer(serializers.ModelSerializer): class Meta: model = models.Album fields = ( - 'id', - 'mbid', - 'title', - 'artist', - 'tracks', - 'release_date', - 'cover', - 'creation_date', + "id", + "mbid", + "title", + "artist", + "tracks", + "release_date", + "cover", + "creation_date", ) def get_tracks(self, o): ordered_tracks = sorted( o.tracks.all(), - key=lambda v: (v.position, v.title) if v.position else (99999, v.title) + key=lambda v: (v.position, v.title) if v.position else (99999, v.title), ) return AlbumTrackSerializer(ordered_tracks, many=True).data @@ -135,13 +114,13 @@ class TrackAlbumSerializer(serializers.ModelSerializer): class Meta: model = models.Album fields = ( - 'id', - 'mbid', - 'title', - 'artist', - 'release_date', - 'cover', - 'creation_date', + "id", + "mbid", + "title", + "artist", + "release_date", + "cover", + "creation_date", ) @@ -154,15 +133,15 @@ class TrackSerializer(serializers.ModelSerializer): class Meta: model = models.Track fields = ( - 'id', - 'mbid', - 'title', - 'album', - 'artist', - 'creation_date', - 'files', - 'position', - 'lyrics', + "id", + "mbid", + "title", + "album", + "artist", + "creation_date", + "files", + "position", + "lyrics", ) def get_lyrics(self, obj): @@ -172,20 +151,19 @@ class TrackSerializer(serializers.ModelSerializer): class TagSerializer(serializers.ModelSerializer): class Meta: model = Tag - fields = ('id', 'name', 'slug') + fields = ("id", "name", "slug") class SimpleAlbumSerializer(serializers.ModelSerializer): - class Meta: model = models.Album - fields = ('id', 'mbid', 'title', 'release_date', 'cover') + fields = ("id", "mbid", "title", "release_date", "cover") class LyricsSerializer(serializers.ModelSerializer): class Meta: model = models.Lyrics - fields = ('id', 'work', 'content', 'content_rendered') + fields = ("id", "work", "content", "content_rendered") class ImportJobSerializer(serializers.ModelSerializer): @@ -193,15 +171,8 @@ class ImportJobSerializer(serializers.ModelSerializer): class Meta: model = models.ImportJob - fields = ( - 'id', - 'mbid', - 'batch', - 'source', - 'status', - 'track_file', - 'audio_file') - read_only_fields = ('status', 'track_file') + fields = ("id", "mbid", "batch", "source", "status", "track_file", "audio_file") + read_only_fields = ("status", "track_file") class ImportBatchSerializer(serializers.ModelSerializer): @@ -210,19 +181,19 @@ class ImportBatchSerializer(serializers.ModelSerializer): class Meta: model = models.ImportBatch fields = ( - 'id', - 'submitted_by', - 'source', - 'status', - 'creation_date', - 'import_request') - read_only_fields = ( - 'creation_date', 'submitted_by', 'source') + "id", + "submitted_by", + "source", + "status", + "creation_date", + "import_request", + ) + read_only_fields = ("creation_date", "submitted_by", "source") def to_representation(self, instance): repr = super().to_representation(instance) try: - repr['job_count'] = instance.job_count + repr["job_count"] = instance.job_count except AttributeError: # Queryset was not annotated pass @@ -231,50 +202,43 @@ class ImportBatchSerializer(serializers.ModelSerializer): class TrackActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() - name = serializers.CharField(source='title') - artist = serializers.CharField(source='artist.name') - album = serializers.CharField(source='album.title') + name = serializers.CharField(source="title") + artist = serializers.CharField(source="artist.name") + album = serializers.CharField(source="album.title") class Meta: model = models.Track - fields = [ - 'id', - 'local_id', - 'name', - 'type', - 'artist', - 'album', - ] + fields = ["id", "local_id", "name", "type", "artist", "album"] def get_type(self, obj): - return 'Audio' + return "Audio" class ImportJobRunSerializer(serializers.Serializer): jobs = serializers.PrimaryKeyRelatedField( many=True, - queryset=models.ImportJob.objects.filter( - status__in=['pending', 'errored'] - ) + queryset=models.ImportJob.objects.filter(status__in=["pending", "errored"]), ) batches = serializers.PrimaryKeyRelatedField( - many=True, - queryset=models.ImportBatch.objects.all() + many=True, queryset=models.ImportBatch.objects.all() ) def validate(self, validated_data): - jobs = validated_data['jobs'] - batches_ids = [b.pk for b in validated_data['batches']] + jobs = validated_data["jobs"] + batches_ids = [b.pk for b in validated_data["batches"]] query = Q(batch__pk__in=batches_ids) query |= Q(pk__in=[j.id for j in jobs]) - queryset = models.ImportJob.objects.filter(query).filter( - status__in=['pending', 'errored']).distinct() - validated_data['_jobs'] = queryset + queryset = ( + models.ImportJob.objects.filter(query) + .filter(status__in=["pending", "errored"]) + .distinct() + ) + validated_data["_jobs"] = queryset return validated_data def create(self, validated_data): - ids = validated_data['_jobs'].values_list('id', flat=True) - validated_data['_jobs'].update(status='pending') + ids = validated_data["_jobs"].values_list("id", flat=True) + validated_data["_jobs"].update(status="pending") for id in ids: tasks.import_job_run.delay(import_job_id=id) - return {'jobs': list(ids)} + return {"jobs": list(ids)} diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 7b1b48981..355af7706 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -1,45 +1,43 @@ import logging import os +from django.conf import settings from django.core.files.base import ContentFile - from musicbrainzngs import ResponseError from funkwhale_api.common import preferences -from funkwhale_api.federation import activity -from funkwhale_api.federation import actors -from funkwhale_api.federation import models as federation_models +from funkwhale_api.federation import activity, actors from funkwhale_api.federation import serializers as federation_serializers -from funkwhale_api.taskapp import celery from funkwhale_api.providers.acoustid import get_acoustid_client from funkwhale_api.providers.audiofile import tasks as audiofile_tasks +from funkwhale_api.taskapp import celery -from django.conf import settings -from . import models from . import lyrics as lyrics_utils +from . import models from . import utils as music_utils logger = logging.getLogger(__name__) -@celery.app.task(name='acoustid.set_on_track_file') -@celery.require_instance(models.TrackFile, 'track_file') +@celery.app.task(name="acoustid.set_on_track_file") +@celery.require_instance(models.TrackFile, "track_file") def set_acoustid_on_track_file(track_file): client = get_acoustid_client() result = client.get_best_match(track_file.audio_file.path) def update(id): track_file.acoustid_track_id = id - track_file.save(update_fields=['acoustid_track_id']) + track_file.save(update_fields=["acoustid_track_id"]) return id + if result: - return update(result['id']) + return update(result["id"]) def import_track_from_remote(library_track): metadata = library_track.metadata try: - track_mbid = metadata['recording']['musicbrainz_id'] + track_mbid = metadata["recording"]["musicbrainz_id"] assert track_mbid # for null/empty values except (KeyError, AssertionError): pass @@ -47,39 +45,43 @@ def import_track_from_remote(library_track): return models.Track.get_or_create_from_api(mbid=track_mbid)[0] try: - album_mbid = metadata['release']['musicbrainz_id'] + album_mbid = metadata["release"]["musicbrainz_id"] assert album_mbid # for null/empty values except (KeyError, AssertionError): pass else: album, _ = models.Album.get_or_create_from_api(mbid=album_mbid) return models.Track.get_or_create_from_title( - library_track.title, artist=album.artist, album=album)[0] + library_track.title, artist=album.artist, album=album + )[0] try: - artist_mbid = metadata['artist']['musicbrainz_id'] + artist_mbid = metadata["artist"]["musicbrainz_id"] assert artist_mbid # for null/empty values except (KeyError, AssertionError): pass else: artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid) album, _ = models.Album.get_or_create_from_title( - library_track.album_title, artist=artist) + library_track.album_title, artist=artist + ) return models.Track.get_or_create_from_title( - library_track.title, artist=artist, album=album)[0] + library_track.title, artist=artist, album=album + )[0] # worst case scenario, we have absolutely no way to link to a # musicbrainz resource, we rely on the name/titles - artist, _ = models.Artist.get_or_create_from_name( - library_track.artist_name) + artist, _ = models.Artist.get_or_create_from_name(library_track.artist_name) album, _ = models.Album.get_or_create_from_title( - library_track.album_title, artist=artist) + library_track.album_title, artist=artist + ) return models.Track.get_or_create_from_title( - library_track.title, artist=artist, album=album)[0] + library_track.title, artist=artist, album=album + )[0] def _do_import(import_job, replace=False, use_acoustid=False): - logger.info('[Import Job %s] starting job', import_job.pk) + logger.info("[Import Job %s] starting job", import_job.pk) from_file = bool(import_job.audio_file) mbid = import_job.mbid acoustid_track_id = None @@ -93,58 +95,60 @@ def _do_import(import_job, replace=False, use_acoustid=False): client = get_acoustid_client() match = client.get_best_match(import_job.audio_file.path) if match: - duration = match['recordings'][0]['duration'] - mbid = match['recordings'][0]['id'] - acoustid_track_id = match['id'] + duration = match["recordings"][0]["duration"] + mbid = match["recordings"][0]["id"] + acoustid_track_id = match["id"] if mbid: logger.info( - '[Import Job %s] importing track from musicbrainz recording %s', + "[Import Job %s] importing track from musicbrainz recording %s", import_job.pk, - str(mbid)) + str(mbid), + ) track, _ = models.Track.get_or_create_from_api(mbid=mbid) elif import_job.audio_file: logger.info( - '[Import Job %s] importing track from uploaded track data at %s', + "[Import Job %s] importing track from uploaded track data at %s", import_job.pk, - import_job.audio_file.path) - track = audiofile_tasks.import_track_data_from_path( - import_job.audio_file.path) + import_job.audio_file.path, + ) + track = audiofile_tasks.import_track_data_from_path(import_job.audio_file.path) elif import_job.library_track: logger.info( - '[Import Job %s] importing track from federated library track %s', + "[Import Job %s] importing track from federated library track %s", import_job.pk, - import_job.library_track.pk) + import_job.library_track.pk, + ) track = import_track_from_remote(import_job.library_track) - elif import_job.source.startswith('file://'): - tf_path = import_job.source.replace('file://', '', 1) + elif import_job.source.startswith("file://"): + tf_path = import_job.source.replace("file://", "", 1) logger.info( - '[Import Job %s] importing track from local track data at %s', + "[Import Job %s] importing track from local track data at %s", import_job.pk, - tf_path) - track = audiofile_tasks.import_track_data_from_path( - tf_path) + tf_path, + ) + track = audiofile_tasks.import_track_data_from_path(tf_path) else: raise ValueError( - 'Not enough data to process import, ' - 'add a mbid, an audio file or a library track') + "Not enough data to process import, " + "add a mbid, an audio file or a library track" + ) track_file = None if replace: - logger.info( - '[Import Job %s] replacing existing audio file', import_job.pk) + logger.info("[Import Job %s] replacing existing audio file", import_job.pk) track_file = track.files.first() elif track.files.count() > 0: logger.info( - '[Import Job %s] skipping, we already have a file for this track', - import_job.pk) + "[Import Job %s] skipping, we already have a file for this track", + import_job.pk, + ) if import_job.audio_file: import_job.audio_file.delete() - import_job.status = 'skipped' + import_job.status = "skipped" import_job.save() return - track_file = track_file or models.TrackFile( - track=track, source=import_job.source) + track_file = track_file or models.TrackFile(track=track, source=import_job.source) track_file.acoustid_track_id = acoustid_track_id if from_file: track_file.audio_file = ContentFile(import_job.audio_file.read()) @@ -158,13 +162,11 @@ def _do_import(import_job, replace=False, use_acoustid=False): else: # no downloading, we hotlink pass - elif not import_job.audio_file and not import_job.source.startswith('file://'): + elif not import_job.audio_file and not import_job.source.startswith("file://"): # not an implace import, and we have a source, so let's download it - logger.info( - '[Import Job %s] downloading audio file from remote', - import_job.pk) + logger.info("[Import Job %s] downloading audio file from remote", import_job.pk) track_file.download_file() - elif not import_job.audio_file and import_job.source.startswith('file://'): + elif not import_job.audio_file and import_job.source.startswith("file://"): # in place import, we set mimetype from extension path, ext = os.path.splitext(import_job.source) track_file.mimetype = music_utils.get_type_from_ext(ext) @@ -172,19 +174,15 @@ def _do_import(import_job, replace=False, use_acoustid=False): track_file.save() # if no cover is set on track album, we try to update it as well: if not track.album.cover: - logger.info( - '[Import Job %s] retrieving album cover', - import_job.pk) + logger.info("[Import Job %s] retrieving album cover", import_job.pk) update_album_cover(track.album, track_file) - import_job.status = 'finished' + import_job.status = "finished" import_job.track_file = track_file if import_job.audio_file: # it's imported on the track, we don't need it anymore import_job.audio_file.delete() import_job.save() - logger.info( - '[Import Job %s] job finished', - import_job.pk) + logger.info("[Import Job %s] job finished", import_job.pk) return track_file @@ -199,20 +197,15 @@ def update_album_cover(album, track_file, replace=False): except FileNotFoundError: metadata = None if metadata: - cover = metadata.get_picture('cover_front') + cover = metadata.get_picture("cover_front") if cover: # best case scenario, cover is embedded in the track - logger.info( - '[Album %s] Using cover embedded in file', - album.pk) + logger.info("[Album %s] Using cover embedded in file", album.pk) return album.get_image(data=cover) - if track_file.source and track_file.source.startswith('file://'): + if track_file.source and track_file.source.startswith("file://"): # let's look for a cover in the same directory - path = os.path.dirname(track_file.source.replace('file://', '', 1)) - logger.info( - '[Album %s] scanning covers from %s', - album.pk, - path) + path = os.path.dirname(track_file.source.replace("file://", "", 1)) + logger.info("[Album %s] scanning covers from %s", album.pk, path) cover = get_cover_from_fs(path) if cover: return album.get_image(data=cover) @@ -220,50 +213,41 @@ def update_album_cover(album, track_file, replace=False): return try: logger.info( - '[Album %s] Fetching cover from musicbrainz release %s', + "[Album %s] Fetching cover from musicbrainz release %s", album.pk, - str(album.mbid)) + str(album.mbid), + ) return album.get_image() except ResponseError as exc: logger.warning( - '[Album %s] cannot fetch cover from musicbrainz: %s', - album.pk, - str(exc)) + "[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc) + ) -IMAGE_TYPES = [ - ('jpg', 'image/jpeg'), - ('png', 'image/png'), -] +IMAGE_TYPES = [("jpg", "image/jpeg"), ("png", "image/png")] + def get_cover_from_fs(dir_path): if os.path.exists(dir_path): for e, m in IMAGE_TYPES: - cover_path = os.path.join(dir_path, 'cover.{}'.format(e)) + cover_path = os.path.join(dir_path, "cover.{}".format(e)) if not os.path.exists(cover_path): - logger.debug('Cover %s does not exists', cover_path) + logger.debug("Cover %s does not exists", cover_path) continue - with open(cover_path, 'rb') as c: - logger.info('Found cover at %s', cover_path) - return { - 'mimetype': m, - 'content': c.read(), - } + with open(cover_path, "rb") as c: + logger.info("Found cover at %s", cover_path) + return {"mimetype": m, "content": c.read()} - -@celery.app.task(name='ImportJob.run', bind=True) +@celery.app.task(name="ImportJob.run", bind=True) @celery.require_instance( - models.ImportJob.objects.filter( - status__in=['pending', 'errored']), - 'import_job') + models.ImportJob.objects.filter(status__in=["pending", "errored"]), "import_job" +) def import_job_run(self, import_job, replace=False, use_acoustid=False): def mark_errored(exc): - logger.error( - '[Import Job %s] Error during import: %s', - import_job.pk, str(exc)) - import_job.status = 'errored' - import_job.save(update_fields=['status']) + logger.error("[Import Job %s] Error during import: %s", import_job.pk, str(exc)) + import_job.status = "errored" + import_job.save(update_fields=["status"]) try: tf = _do_import(import_job, replace, use_acoustid=use_acoustid) @@ -272,65 +256,63 @@ def import_job_run(self, import_job, replace=False, use_acoustid=False): if not settings.DEBUG: try: self.retry(exc=exc, countdown=30, max_retries=3) - except: + except Exception: mark_errored(exc) raise mark_errored(exc) raise -@celery.app.task(name='ImportBatch.run') -@celery.require_instance(models.ImportBatch, 'import_batch') +@celery.app.task(name="ImportBatch.run") +@celery.require_instance(models.ImportBatch, "import_batch") def import_batch_run(import_batch): - for job_id in import_batch.jobs.order_by('id').values_list('id', flat=True): + for job_id in import_batch.jobs.order_by("id").values_list("id", flat=True): import_job_run.delay(import_job_id=job_id) -@celery.app.task(name='Lyrics.fetch_content') -@celery.require_instance(models.Lyrics, 'lyrics') +@celery.app.task(name="Lyrics.fetch_content") +@celery.require_instance(models.Lyrics, "lyrics") def fetch_content(lyrics): html = lyrics_utils._get_html(lyrics.url) content = lyrics_utils.extract_content(html) cleaned_content = lyrics_utils.clean_content(content) lyrics.content = cleaned_content - lyrics.save(update_fields=['content']) + lyrics.save(update_fields=["content"]) -@celery.app.task(name='music.import_batch_notify_followers') +@celery.app.task(name="music.import_batch_notify_followers") @celery.require_instance( - models.ImportBatch.objects.filter(status='finished'), 'import_batch') + models.ImportBatch.objects.filter(status="finished"), "import_batch" +) def import_batch_notify_followers(import_batch): - if not preferences.get('federation__enabled'): + if not preferences.get("federation__enabled"): return - if import_batch.source == 'federation': + if import_batch.source == "federation": return - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() followers = library_actor.get_approved_followers() jobs = import_batch.jobs.filter( - status='finished', - library_track__isnull=True, - track_file__isnull=False, - ).select_related( - 'track_file__track__artist', - 'track_file__track__album__artist', - ) + status="finished", library_track__isnull=True, track_file__isnull=False + ).select_related("track_file__track__artist", "track_file__track__album__artist") track_files = [job.track_file for job in jobs] - collection = federation_serializers.CollectionSerializer({ - 'actor': library_actor, - 'id': import_batch.get_federation_url(), - 'items': track_files, - 'item_serializer': federation_serializers.AudioSerializer - }).data + collection = federation_serializers.CollectionSerializer( + { + "actor": library_actor, + "id": import_batch.get_federation_url(), + "items": track_files, + "item_serializer": federation_serializers.AudioSerializer, + } + ).data for f in followers: create = federation_serializers.ActivitySerializer( { - 'type': 'Create', - 'id': collection['id'], - 'object': collection, - 'actor': library_actor.url, - 'to': [f.url], + "type": "Create", + "id": collection["id"], + "object": collection, + "actor": library_actor.url, + "to": [f.url], } ).data diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 3b9fbb214..3080c1c6c 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -1,34 +1,36 @@ -import magic import mimetypes -import mutagen import re +import magic +import mutagen from django.db.models import Q -def normalize_query(query_string, - findterms=re.compile(r'"([^"]+)"|(\S+)').findall, - normspace=re.compile(r'\s{2,}').sub): - ''' Splits the query string in invidual keywords, getting rid of unecessary spaces +def normalize_query( + query_string, + findterms=re.compile(r'"([^"]+)"|(\S+)').findall, + normspace=re.compile(r"\s{2,}").sub, +): + """ Splits the query string in invidual keywords, getting rid of unecessary spaces and grouping quoted words together. Example: >>> normalize_query(' some random words "with quotes " and spaces') ['some', 'random', 'words', 'with quotes', 'and', 'spaces'] - ''' - return [normspace(' ', (t[0] or t[1]).strip()) for t in findterms(query_string)] + """ + return [normspace(" ", (t[0] or t[1]).strip()) for t in findterms(query_string)] def get_query(query_string, search_fields): - ''' Returns a query, that is a combination of Q objects. That combination + """ Returns a query, that is a combination of Q objects. That combination aims to search keywords within a model by testing the given search fields. - ''' - query = None # Query to search for every search term + """ + query = None # Query to search for every search term terms = normalize_query(query_string) for term in terms: - or_query = None # Query to search for a given term in each field + or_query = None # Query to search for a given term in each field for field_name in search_fields: q = Q(**{"%s__icontains" % field_name: term}) if or_query is None: @@ -45,7 +47,7 @@ def get_query(query_string, search_fields): def guess_mimetype(f): b = min(1000000, f.size) t = magic.from_buffer(f.read(b), mime=True) - if not t.startswith('audio/'): + if not t.startswith("audio/"): # failure, we try guessing by extension mt, _ = mimetypes.guess_type(f.path) if mt: @@ -54,20 +56,20 @@ def guess_mimetype(f): def compute_status(jobs): - statuses = jobs.order_by().values_list('status', flat=True).distinct() - errored = any([status == 'errored' for status in statuses]) + statuses = jobs.order_by().values_list("status", flat=True).distinct() + errored = any([status == "errored" for status in statuses]) if errored: - return 'errored' - pending = any([status == 'pending' for status in statuses]) + return "errored" + pending = any([status == "pending" for status in statuses]) if pending: - return 'pending' - return 'finished' + return "pending" + return "finished" AUDIO_EXTENSIONS_AND_MIMETYPE = [ - ('ogg', 'audio/ogg'), - ('mp3', 'audio/mpeg'), - ('flac', 'audio/x-flac'), + ("ogg", "audio/ogg"), + ("mp3", "audio/mpeg"), + ("flac", "audio/x-flac"), ] EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE} @@ -79,7 +81,7 @@ def get_ext_from_type(mimetype): def get_type_from_ext(extension): - if extension.startswith('.'): + if extension.startswith("."): # we remove leading dot extension = extension[1:] return EXTENSION_TO_MIMETYPE.get(extension) @@ -90,7 +92,7 @@ def get_audio_file_data(f): if not data: return d = {} - d['bitrate'] = data.info.bitrate - d['length'] = data.info.length + d["bitrate"] = data.info.bitrate + d["length"] = data.info.length return d diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 2850c0770..77a82dd21 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -1,55 +1,40 @@ -import ffmpeg -import os import json import logging -import subprocess -import unicodedata import urllib -from django.contrib.auth.decorators import login_required -from django.core.exceptions import ObjectDoesNotExist from django.conf import settings -from django.db import models, transaction -from django.db.models.functions import Length +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction from django.db.models import Count -from django.http import StreamingHttpResponse -from django.urls import reverse +from django.db.models.functions import Length from django.utils import timezone -from django.utils.decorators import method_decorator - -from rest_framework import viewsets, views, mixins +from musicbrainzngs import ResponseError +from rest_framework import mixins +from rest_framework import settings as rest_settings +from rest_framework import views, viewsets from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response -from rest_framework import settings as rest_settings -from rest_framework import permissions -from musicbrainzngs import ResponseError +from taggit.models import Tag from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.common.permissions import ConditionalAuthentication -from funkwhale_api.users.permissions import HasUserPermission -from taggit.models import Tag -from funkwhale_api.federation import actors from funkwhale_api.federation.authentication import SignatureAuthentication from funkwhale_api.federation.models import LibraryTrack from funkwhale_api.musicbrainz import api from funkwhale_api.requests.models import ImportRequest +from funkwhale_api.users.permissions import HasUserPermission -from . import filters -from . import importers -from . import models +from . import filters, importers, models from . import permissions as music_permissions -from . import serializers -from . import tasks -from . import utils +from . import serializers, tasks, utils logger = logging.getLogger(__name__) class TagViewSetMixin(object): - def get_queryset(self): queryset = super().get_queryset() - tag = self.request.query_params.get('tag') + tag = self.request.query_params.get("tag") if tag: queryset = queryset.filter(tags__pk=tag) return queryset @@ -60,38 +45,37 @@ class ArtistViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = serializers.ArtistWithAlbumsSerializer permission_classes = [ConditionalAuthentication] filter_class = filters.ArtistFilter - ordering_fields = ('id', 'name', 'creation_date') + ordering_fields = ("id", "name", "creation_date") class AlbumViewSet(viewsets.ReadOnlyModelViewSet): queryset = ( models.Album.objects.all() - .order_by('artist', 'release_date') - .select_related() - .prefetch_related( - 'tracks__artist', - 'tracks__files')) + .order_by("artist", "release_date") + .select_related() + .prefetch_related("tracks__artist", "tracks__files") + ) serializer_class = serializers.AlbumSerializer permission_classes = [ConditionalAuthentication] - ordering_fields = ('creation_date', 'release_date', 'title') + ordering_fields = ("creation_date", "release_date", "title") filter_class = filters.AlbumFilter class ImportBatchViewSet( - mixins.CreateModelMixin, - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - viewsets.GenericViewSet): + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): queryset = ( - models.ImportBatch.objects - .select_related() - .order_by('-creation_date') - .annotate(job_count=Count('jobs')) + models.ImportBatch.objects.select_related() + .order_by("-creation_date") + .annotate(job_count=Count("jobs")) ) serializer_class = serializers.ImportBatchSerializer permission_classes = (HasUserPermission,) - required_permissions = ['library', 'upload'] - permission_operator = 'or' + required_permissions = ["library", "upload"] + permission_operator = "or" filter_class = filters.ImportBatchFilter def perform_create(self, serializer): @@ -101,51 +85,50 @@ class ImportBatchViewSet( qs = super().get_queryset() # if user do not have library permission, we limit to their # own jobs - if not self.request.user.has_permissions('library'): + if not self.request.user.has_permissions("library"): qs = qs.filter(submitted_by=self.request.user) return qs class ImportJobViewSet( - mixins.CreateModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): - queryset = (models.ImportJob.objects.all().select_related()) + mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet +): + queryset = models.ImportJob.objects.all().select_related() serializer_class = serializers.ImportJobSerializer permission_classes = (HasUserPermission,) - required_permissions = ['library', 'upload'] - permission_operator = 'or' + required_permissions = ["library", "upload"] + permission_operator = "or" filter_class = filters.ImportJobFilter def get_queryset(self): qs = super().get_queryset() # if user do not have library permission, we limit to their # own jobs - if not self.request.user.has_permissions('library'): + if not self.request.user.has_permissions("library"): qs = qs.filter(batch__submitted_by=self.request.user) return qs - @list_route(methods=['get']) + @list_route(methods=["get"]) def stats(self, request, *args, **kwargs): - if not request.user.has_permissions('library'): + if not request.user.has_permissions("library"): return Response(status=403) qs = models.ImportJob.objects.all() filterset = filters.ImportJobFilter(request.GET, queryset=qs) qs = filterset.qs - qs = qs.values('status').order_by('status') - qs = qs.annotate(status_count=Count('status')) + qs = qs.values("status").order_by("status") + qs = qs.annotate(status_count=Count("status")) data = {} for row in qs: - data[row['status']] = row['status_count'] + data[row["status"]] = row["status_count"] for s, _ in models.IMPORT_STATUS_CHOICES: data.setdefault(s, 0) - data['count'] = sum([v for v in data.values()]) + data["count"] = sum([v for v in data.values()]) return Response(data) - @list_route(methods=['post']) + @list_route(methods=["post"]) def run(self, request, *args, **kwargs): serializer = serializers.ImportJobRunSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -154,11 +137,10 @@ class ImportJobViewSet( return Response(payload) def perform_create(self, serializer): - source = 'file://' + serializer.validated_data['audio_file'].name + source = "file://" + serializer.validated_data["audio_file"].name serializer.save(source=source) funkwhale_utils.on_commit( - tasks.import_job_run.delay, - import_job_id=serializer.instance.pk + tasks.import_job_run.delay, import_job_id=serializer.instance.pk ) @@ -166,33 +148,34 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): """ A simple ViewSet for viewing and editing accounts. """ - queryset = (models.Track.objects.all().for_nested_serialization()) + + queryset = models.Track.objects.all().for_nested_serialization() serializer_class = serializers.TrackSerializer permission_classes = [ConditionalAuthentication] filter_class = filters.TrackFilter ordering_fields = ( - 'creation_date', - 'title', - 'album__title', - 'album__release_date', - 'position', - 'artist__name', + "creation_date", + "title", + "album__title", + "album__release_date", + "position", + "artist__name", ) def get_queryset(self): queryset = super().get_queryset() - filter_favorites = self.request.GET.get('favorites', None) + filter_favorites = self.request.GET.get("favorites", None) user = self.request.user - if user.is_authenticated and filter_favorites == 'true': + if user.is_authenticated and filter_favorites == "true": queryset = queryset.filter(track_favorites__user=user) return queryset - @detail_route(methods=['get']) + @detail_route(methods=["get"]) @transaction.non_atomic_requests def lyrics(self, request, *args, **kwargs): try: - track = models.Track.objects.get(pk=kwargs['pk']) + track = models.Track.objects.get(pk=kwargs["pk"]) except models.Track.DoesNotExist: return Response(status=404) @@ -201,7 +184,7 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): work = track.get_work() if not work: - return Response({'error': 'unavailable work '}, status=404) + return Response({"error": "unavailable work "}, status=404) lyrics = work.fetch_lyrics() try: @@ -209,7 +192,7 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): tasks.fetch_content(lyrics_id=lyrics.pk) lyrics.refresh_from_db() except AttributeError: - return Response({'error': 'unavailable lyrics'}, status=404) + return Response({"error": "unavailable lyrics"}, status=404) serializer = serializers.LyricsSerializer(lyrics) return Response(serializer.data) @@ -218,7 +201,7 @@ def get_file_path(audio_file): serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH prefix = settings.MUSIC_DIRECTORY_PATH t = settings.REVERSE_PROXY_TYPE - if t == 'nginx': + if t == "nginx": # we have to use the internal locations try: path = audio_file.url @@ -226,30 +209,30 @@ def get_file_path(audio_file): # a path was given if not serve_path or not prefix: raise ValueError( - 'You need to specify MUSIC_DIRECTORY_SERVE_PATH and ' - 'MUSIC_DIRECTORY_PATH to serve in-place imported files' + "You need to specify MUSIC_DIRECTORY_SERVE_PATH and " + "MUSIC_DIRECTORY_PATH to serve in-place imported files" ) - path = '/music' + audio_file.replace(prefix, '', 1) - return (settings.PROTECT_FILES_PATH + path).encode('utf-8') - if t == 'apache2': + path = "/music" + audio_file.replace(prefix, "", 1) + return (settings.PROTECT_FILES_PATH + path).encode("utf-8") + if t == "apache2": try: path = audio_file.path except AttributeError: # a path was given if not serve_path or not prefix: raise ValueError( - 'You need to specify MUSIC_DIRECTORY_SERVE_PATH and ' - 'MUSIC_DIRECTORY_PATH to serve in-place imported files' + "You need to specify MUSIC_DIRECTORY_SERVE_PATH and " + "MUSIC_DIRECTORY_PATH to serve in-place imported files" ) path = audio_file.replace(prefix, serve_path, 1) - return path.encode('utf-8') + return path.encode("utf-8") def handle_serve(track_file): f = track_file # we update the accessed_date f.accessed_date = timezone.now() - f.save(update_fields=['accessed_date']) + f.save(update_fields=["accessed_date"]) mt = f.mimetype audio_file = f.audio_file @@ -270,28 +253,24 @@ def handle_serve(track_file): library_track.download_audio() track_file.library_track = library_track track_file.set_audio_data() - track_file.save(update_fields=['bitrate', 'duration', 'size']) + track_file.save(update_fields=["bitrate", "duration", "size"]) audio_file = library_track.audio_file file_path = get_file_path(audio_file) mt = library_track.audio_mimetype elif audio_file: file_path = get_file_path(audio_file) - elif f.source and f.source.startswith('file://'): - file_path = get_file_path(f.source.replace('file://', '', 1)) + elif f.source and f.source.startswith("file://"): + file_path = get_file_path(f.source.replace("file://", "", 1)) if mt: response = Response(content_type=mt) else: response = Response() filename = f.filename - mapping = { - 'nginx': 'X-Accel-Redirect', - 'apache2': 'X-Sendfile', - } + mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"} file_header = mapping[settings.REVERSE_PROXY_TYPE] response[file_header] = file_path - filename = "filename*=UTF-8''{}".format( - urllib.parse.quote(filename)) + filename = "filename*=UTF-8''{}".format(urllib.parse.quote(filename)) response["Content-Disposition"] = "attachment; {}".format(filename) if mt: response["Content-Type"] = mt @@ -302,30 +281,29 @@ def handle_serve(track_file): class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): queryset = ( models.TrackFile.objects.all() - .select_related('track__artist', 'track__album') - .order_by('-id') + .select_related("track__artist", "track__album") + .order_by("-id") ) serializer_class = serializers.TrackFileSerializer - authentication_classes = rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [ - SignatureAuthentication - ] + authentication_classes = ( + rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + + [SignatureAuthentication] + ) permission_classes = [music_permissions.Listen] - @detail_route(methods=['get']) + @detail_route(methods=["get"]) def serve(self, request, *args, **kwargs): queryset = models.TrackFile.objects.select_related( - 'library_track', - 'track__album__artist', - 'track__artist', + "library_track", "track__album__artist", "track__artist" ) try: - return handle_serve(queryset.get(pk=kwargs['pk'])) + return handle_serve(queryset.get(pk=kwargs["pk"])) except models.TrackFile.DoesNotExist: return Response(status=404) class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all().order_by('name') + queryset = Tag.objects.all().order_by("name") serializer_class = serializers.TagSerializer permission_classes = [ConditionalAuthentication] @@ -335,85 +313,91 @@ class Search(views.APIView): permission_classes = [ConditionalAuthentication] def get(self, request, *args, **kwargs): - query = request.GET['query'] + query = request.GET["query"] results = { # 'tags': serializers.TagSerializer(self.get_tags(query), many=True).data, - 'artists': serializers.ArtistWithAlbumsSerializer(self.get_artists(query), many=True).data, - 'tracks': serializers.TrackSerializer(self.get_tracks(query), many=True).data, - 'albums': serializers.AlbumSerializer(self.get_albums(query), many=True).data, + "artists": serializers.ArtistWithAlbumsSerializer( + self.get_artists(query), many=True + ).data, + "tracks": serializers.TrackSerializer( + self.get_tracks(query), many=True + ).data, + "albums": serializers.AlbumSerializer( + self.get_albums(query), many=True + ).data, } return Response(results, status=200) def get_tracks(self, query): search_fields = [ - 'mbid', - 'title__unaccent', - 'album__title__unaccent', - 'artist__name__unaccent'] + "mbid", + "title__unaccent", + "album__title__unaccent", + "artist__name__unaccent", + ] query_obj = utils.get_query(query, search_fields) return ( models.Track.objects.all() - .filter(query_obj) - .select_related('artist', 'album__artist') - .prefetch_related('files') - )[:self.max_results] + .filter(query_obj) + .select_related("artist", "album__artist") + .prefetch_related("files") + )[: self.max_results] def get_albums(self, query): - search_fields = [ - 'mbid', - 'title__unaccent', - 'artist__name__unaccent'] + search_fields = ["mbid", "title__unaccent", "artist__name__unaccent"] query_obj = utils.get_query(query, search_fields) return ( models.Album.objects.all() - .filter(query_obj) - .select_related() - .prefetch_related( - 'tracks__files', - ) - )[:self.max_results] + .filter(query_obj) + .select_related() + .prefetch_related("tracks__files") + )[: self.max_results] def get_artists(self, query): - search_fields = ['mbid', 'name__unaccent'] + search_fields = ["mbid", "name__unaccent"] query_obj = utils.get_query(query, search_fields) - return ( - models.Artist.objects.all() - .filter(query_obj) - .with_albums() - )[:self.max_results] + return (models.Artist.objects.all().filter(query_obj).with_albums())[ + : self.max_results + ] def get_tags(self, query): - search_fields = ['slug', 'name__unaccent'] + search_fields = ["slug", "name__unaccent"] query_obj = utils.get_query(query, search_fields) # We want the shortest tag first - qs = Tag.objects.all().annotate(slug_length=Length('slug')).order_by('slug_length') + qs = ( + Tag.objects.all() + .annotate(slug_length=Length("slug")) + .order_by("slug_length") + ) - return qs.filter(query_obj)[:self.max_results] + return qs.filter(query_obj)[: self.max_results] class SubmitViewSet(viewsets.ViewSet): queryset = models.ImportBatch.objects.none() permission_classes = (HasUserPermission,) - required_permissions = ['library'] + required_permissions = ["library"] - @list_route(methods=['post']) + @list_route(methods=["post"]) @transaction.non_atomic_requests def single(self, request, *args, **kwargs): try: - models.Track.objects.get(mbid=request.POST['mbid']) + models.Track.objects.get(mbid=request.POST["mbid"]) return Response({}) except models.Track.DoesNotExist: pass batch = models.ImportBatch.objects.create(submitted_by=request.user) - job = models.ImportJob.objects.create(mbid=request.POST['mbid'], batch=batch, source=request.POST['import_url']) + job = models.ImportJob.objects.create( + mbid=request.POST["mbid"], batch=batch, source=request.POST["import_url"] + ) tasks.import_job_run.delay(import_job_id=job.pk) serializer = serializers.ImportBatchSerializer(batch) return Response(serializer.data, status=201) def get_import_request(self, data): try: - raw = data['importRequest'] + raw = data["importRequest"] except KeyError: return @@ -423,57 +407,64 @@ class SubmitViewSet(viewsets.ViewSet): except ImportRequest.DoesNotExist: pass - @list_route(methods=['post']) + @list_route(methods=["post"]) @transaction.non_atomic_requests def album(self, request, *args, **kwargs): - data = json.loads(request.body.decode('utf-8')) + data = json.loads(request.body.decode("utf-8")) import_request = self.get_import_request(data) import_data, batch = self._import_album( - data, request, batch=None, import_request=import_request) + data, request, batch=None, import_request=import_request + ) return Response(import_data) @transaction.atomic def _import_album(self, data, request, batch=None, import_request=None): # we import the whole album here to prevent race conditions that occurs # when using get_or_create_from_api in tasks - album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release'] + album_data = api.releases.get( + id=data["releaseId"], includes=models.Album.api_includes + )["release"] cleaned_data = models.Album.clean_musicbrainz_data(album_data) - album = importers.load(models.Album, cleaned_data, album_data, import_hooks=[models.import_tracks]) + album = importers.load( + models.Album, cleaned_data, album_data, import_hooks=[models.import_tracks] + ) try: album.get_image() except ResponseError: pass if not batch: batch = models.ImportBatch.objects.create( - submitted_by=request.user, - import_request=import_request) - for row in data['tracks']: + submitted_by=request.user, import_request=import_request + ) + for row in data["tracks"]: try: - models.TrackFile.objects.get(track__mbid=row['mbid']) + models.TrackFile.objects.get(track__mbid=row["mbid"]) except models.TrackFile.DoesNotExist: - job = models.ImportJob.objects.create(mbid=row['mbid'], batch=batch, source=row['source']) + job = models.ImportJob.objects.create( + mbid=row["mbid"], batch=batch, source=row["source"] + ) funkwhale_utils.on_commit( - tasks.import_job_run.delay, - import_job_id=job.pk + tasks.import_job_run.delay, import_job_id=job.pk ) serializer = serializers.ImportBatchSerializer(batch) return serializer.data, batch - @list_route(methods=['post']) + @list_route(methods=["post"]) @transaction.non_atomic_requests def artist(self, request, *args, **kwargs): - data = json.loads(request.body.decode('utf-8')) + data = json.loads(request.body.decode("utf-8")) import_request = self.get_import_request(data) - artist_data = api.artists.get(id=data['artistId'])['artist'] + artist_data = api.artists.get(id=data["artistId"])["artist"] cleaned_data = models.Artist.clean_musicbrainz_data(artist_data) - artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[]) + importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[]) import_data = [] batch = None - for row in data['albums']: + for row in data["albums"]: row_data, batch = self._import_album( - row, request, batch=batch, import_request=import_request) + row, request, batch=batch, import_request=import_request + ) import_data.append(row_data) return Response(import_data[0]) diff --git a/api/funkwhale_api/musicbrainz/__init__.py b/api/funkwhale_api/musicbrainz/__init__.py index 00aa85d5c..103da679f 100644 --- a/api/funkwhale_api/musicbrainz/__init__.py +++ b/api/funkwhale_api/musicbrainz/__init__.py @@ -1 +1,3 @@ from .client import api + +__all__ = ["api"] diff --git a/api/funkwhale_api/musicbrainz/client.py b/api/funkwhale_api/musicbrainz/client.py index 8e7076a78..deae0672f 100644 --- a/api/funkwhale_api/musicbrainz/client.py +++ b/api/funkwhale_api/musicbrainz/client.py @@ -1,21 +1,21 @@ -import musicbrainzngs import memoize.djangocache - +import musicbrainzngs from django.conf import settings + from funkwhale_api import __version__ _api = musicbrainzngs -_api.set_useragent('funkwhale', str(__version__), settings.FUNKWHALE_URL) +_api.set_useragent("funkwhale", str(__version__), settings.FUNKWHALE_URL) -store = memoize.djangocache.Cache('default') -memo = memoize.Memoizer(store, namespace='memoize:musicbrainz') +store = memoize.djangocache.Cache("default") +memo = memoize.Memoizer(store, namespace="memoize:musicbrainz") def clean_artist_search(query, **kwargs): cleaned_kwargs = {} - if kwargs.get('name'): - cleaned_kwargs['artist'] = kwargs.get('name') + if kwargs.get("name"): + cleaned_kwargs["artist"] = kwargs.get("name") return _api.search_artists(query, **cleaned_kwargs) @@ -23,55 +23,43 @@ class API(object): _api = _api class artists(object): - search = memo( - clean_artist_search, max_age=settings.MUSICBRAINZ_CACHE_DURATION) - get = memo( - _api.get_artist_by_id, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) + search = memo(clean_artist_search, max_age=settings.MUSICBRAINZ_CACHE_DURATION) + get = memo(_api.get_artist_by_id, max_age=settings.MUSICBRAINZ_CACHE_DURATION) class images(object): get_front = memo( - _api.get_image_front, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) + _api.get_image_front, max_age=settings.MUSICBRAINZ_CACHE_DURATION + ) class recordings(object): search = memo( - _api.search_recordings, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) + _api.search_recordings, max_age=settings.MUSICBRAINZ_CACHE_DURATION + ) get = memo( - _api.get_recording_by_id, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) + _api.get_recording_by_id, max_age=settings.MUSICBRAINZ_CACHE_DURATION + ) class works(object): - search = memo( - _api.search_works, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) - get = memo( - _api.get_work_by_id, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) + search = memo(_api.search_works, max_age=settings.MUSICBRAINZ_CACHE_DURATION) + get = memo(_api.get_work_by_id, max_age=settings.MUSICBRAINZ_CACHE_DURATION) class releases(object): - search = memo( - _api.search_releases, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) - get = memo( - _api.get_release_by_id, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) - browse = memo( - _api.browse_releases, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) + search = memo(_api.search_releases, max_age=settings.MUSICBRAINZ_CACHE_DURATION) + get = memo(_api.get_release_by_id, max_age=settings.MUSICBRAINZ_CACHE_DURATION) + browse = memo(_api.browse_releases, max_age=settings.MUSICBRAINZ_CACHE_DURATION) # get_image_front = _api.get_image_front class release_groups(object): search = memo( - _api.search_release_groups, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) + _api.search_release_groups, max_age=settings.MUSICBRAINZ_CACHE_DURATION + ) get = memo( - _api.get_release_group_by_id, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) + _api.get_release_group_by_id, max_age=settings.MUSICBRAINZ_CACHE_DURATION + ) browse = memo( - _api.browse_release_groups, - max_age=settings.MUSICBRAINZ_CACHE_DURATION) + _api.browse_release_groups, max_age=settings.MUSICBRAINZ_CACHE_DURATION + ) # get_image_front = _api.get_image_front + api = API() diff --git a/api/funkwhale_api/musicbrainz/urls.py b/api/funkwhale_api/musicbrainz/urls.py index 7befe49ab..d14447f14 100644 --- a/api/funkwhale_api/musicbrainz/urls.py +++ b/api/funkwhale_api/musicbrainz/urls.py @@ -1,23 +1,31 @@ -from django.conf.urls import include, url +from django.conf.urls import url from rest_framework import routers from . import views router = routers.SimpleRouter() -router.register(r'search', views.SearchViewSet, 'search') +router.register(r"search", views.SearchViewSet, "search") urlpatterns = [ - url('releases/(?P[0-9a-z-]+)/$', + url( + "releases/(?P[0-9a-z-]+)/$", views.ReleaseDetail.as_view(), - name='release-detail'), - url('artists/(?P[0-9a-z-]+)/$', + name="release-detail", + ), + url( + "artists/(?P[0-9a-z-]+)/$", views.ArtistDetail.as_view(), - name='artist-detail'), - url('release-groups/browse/(?P[0-9a-z-]+)/$', + name="artist-detail", + ), + url( + "release-groups/browse/(?P[0-9a-z-]+)/$", views.ReleaseGroupBrowse.as_view(), - name='release-group-browse'), - url('releases/browse/(?P[0-9a-z-]+)/$', + name="release-group-browse", + ), + url( + "releases/browse/(?P[0-9a-z-]+)/$", views.ReleaseBrowse.as_view(), - name='release-browse'), + name="release-browse", + ), # url('release-groups/(?P[0-9a-z-]+)/$', # views.ReleaseGroupDetail.as_view(), # name='release-group-detail'), diff --git a/api/funkwhale_api/musicbrainz/views.py b/api/funkwhale_api/musicbrainz/views.py index 5c101b161..b6f009dca 100644 --- a/api/funkwhale_api/musicbrainz/views.py +++ b/api/funkwhale_api/musicbrainz/views.py @@ -1,12 +1,10 @@ from rest_framework import viewsets -from rest_framework.views import APIView -from rest_framework.response import Response from rest_framework.decorators import list_route -import musicbrainzngs +from rest_framework.response import Response +from rest_framework.views import APIView from funkwhale_api.common.permissions import ConditionalAuthentication - from .client import api @@ -14,8 +12,7 @@ class ReleaseDetail(APIView): permission_classes = [ConditionalAuthentication] def get(self, request, *args, **kwargs): - result = api.releases.get( - id=kwargs['uuid'], includes=['artists', 'recordings']) + result = api.releases.get(id=kwargs["uuid"], includes=["artists", "recordings"]) return Response(result) @@ -23,9 +20,7 @@ class ArtistDetail(APIView): permission_classes = [ConditionalAuthentication] def get(self, request, *args, **kwargs): - result = api.artists.get( - id=kwargs['uuid'], - includes=['release-groups']) + result = api.artists.get(id=kwargs["uuid"], includes=["release-groups"]) # import json; print(json.dumps(result, indent=4)) return Response(result) @@ -34,8 +29,7 @@ class ReleaseGroupBrowse(APIView): permission_classes = [ConditionalAuthentication] def get(self, request, *args, **kwargs): - result = api.release_groups.browse( - artist=kwargs['artist_uuid']) + result = api.release_groups.browse(artist=kwargs["artist_uuid"]) return Response(result) @@ -44,29 +38,30 @@ class ReleaseBrowse(APIView): def get(self, request, *args, **kwargs): result = api.releases.browse( - release_group=kwargs['release_group_uuid'], - includes=['recordings', 'artist-credits']) + release_group=kwargs["release_group_uuid"], + includes=["recordings", "artist-credits"], + ) return Response(result) class SearchViewSet(viewsets.ViewSet): permission_classes = [ConditionalAuthentication] - @list_route(methods=['get']) + @list_route(methods=["get"]) def recordings(self, request, *args, **kwargs): - query = request.GET['query'] + query = request.GET["query"] results = api.recordings.search(query) return Response(results) - @list_route(methods=['get']) + @list_route(methods=["get"]) def releases(self, request, *args, **kwargs): - query = request.GET['query'] + query = request.GET["query"] results = api.releases.search(query) return Response(results) - @list_route(methods=['get']) + @list_route(methods=["get"]) def artists(self, request, *args, **kwargs): - query = request.GET['query'] + query = request.GET["query"] results = api.artists.search(query) # results = musicbrainzngs.search_artists(query) return Response(results) diff --git a/api/funkwhale_api/playlists/admin.py b/api/funkwhale_api/playlists/admin.py index 68e447f38..98ced232e 100644 --- a/api/funkwhale_api/playlists/admin.py +++ b/api/funkwhale_api/playlists/admin.py @@ -5,13 +5,13 @@ from . import models @admin.register(models.Playlist) class PlaylistAdmin(admin.ModelAdmin): - list_display = ['name', 'user', 'privacy_level', 'creation_date'] - search_fields = ['name', ] + list_display = ["name", "user", "privacy_level", "creation_date"] + search_fields = ["name"] list_select_related = True @admin.register(models.PlaylistTrack) class PlaylistTrackAdmin(admin.ModelAdmin): - list_display = ['playlist', 'track', 'index'] - search_fields = ['track__name', 'playlist__name'] + list_display = ["playlist", "track", "index"] + search_fields = ["track__name", "playlist__name"] list_select_related = True diff --git a/api/funkwhale_api/playlists/dynamic_preferences_registry.py b/api/funkwhale_api/playlists/dynamic_preferences_registry.py index b717177a2..5a2043452 100644 --- a/api/funkwhale_api/playlists/dynamic_preferences_registry.py +++ b/api/funkwhale_api/playlists/dynamic_preferences_registry.py @@ -3,16 +3,14 @@ from dynamic_preferences.registries import global_preferences_registry from funkwhale_api.common import preferences -playlists = types.Section('playlists') +playlists = types.Section("playlists") @global_preferences_registry.register class MaxTracks(preferences.DefaultFromSettingMixin, types.IntegerPreference): show_in_api = True section = playlists - name = 'max_tracks' - verbose_name = 'Max tracks per playlist' - setting = 'PLAYLISTS_MAX_TRACKS' - field_kwargs = { - 'required': False, - } + name = "max_tracks" + verbose_name = "Max tracks per playlist" + setting = "PLAYLISTS_MAX_TRACKS" + field_kwargs = {"required": False} diff --git a/api/funkwhale_api/playlists/factories.py b/api/funkwhale_api/playlists/factories.py index cddea6002..ff031945a 100644 --- a/api/funkwhale_api/playlists/factories.py +++ b/api/funkwhale_api/playlists/factories.py @@ -7,11 +7,11 @@ from funkwhale_api.users.factories import UserFactory @registry.register class PlaylistFactory(factory.django.DjangoModelFactory): - name = factory.Faker('name') + name = factory.Faker("name") user = factory.SubFactory(UserFactory) class Meta: - model = 'playlists.Playlist' + model = "playlists.Playlist" @registry.register @@ -20,4 +20,4 @@ class PlaylistTrackFactory(factory.django.DjangoModelFactory): track = factory.SubFactory(TrackFactory) class Meta: - model = 'playlists.PlaylistTrack' + model = "playlists.PlaylistTrack" diff --git a/api/funkwhale_api/playlists/filters.py b/api/funkwhale_api/playlists/filters.py index bc4941510..ae9f0226f 100644 --- a/api/funkwhale_api/playlists/filters.py +++ b/api/funkwhale_api/playlists/filters.py @@ -5,18 +5,13 @@ from funkwhale_api.music import utils from . import models - class PlaylistFilter(filters.FilterSet): - q = filters.CharFilter(name='_', method='filter_q') + q = filters.CharFilter(name="_", method="filter_q") class Meta: model = models.Playlist - fields = { - 'user': ['exact'], - 'name': ['exact', 'icontains'], - 'q': 'exact', - } + fields = {"user": ["exact"], "name": ["exact", "icontains"], "q": "exact"} def filter_q(self, queryset, name, value): - query = utils.get_query(value, ['name', 'user__username']) + query = utils.get_query(value, ["name", "user__username"]) return queryset.filter(query) diff --git a/api/funkwhale_api/playlists/migrations/0001_initial.py b/api/funkwhale_api/playlists/migrations/0001_initial.py index 987b2f9cf..68e66d763 100644 --- a/api/funkwhale_api/playlists/migrations/0001_initial.py +++ b/api/funkwhale_api/playlists/migrations/0001_initial.py @@ -10,34 +10,84 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('music', '0012_auto_20161122_1905'), + ("music", "0012_auto_20161122_1905"), ] operations = [ migrations.CreateModel( - name='Playlist', + name="Playlist", fields=[ - ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), - ('name', models.CharField(max_length=50)), - ('is_public', models.BooleanField(default=False)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='playlists', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + auto_created=True, + verbose_name="ID", + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=50)), + ("is_public", models.BooleanField(default=False)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "user", + models.ForeignKey( + to=settings.AUTH_USER_MODEL, + related_name="playlists", + on_delete=models.CASCADE, + ), + ), ], ), migrations.CreateModel( - name='PlaylistTrack', + name="PlaylistTrack", fields=[ - ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), - ('lft', models.PositiveIntegerField(db_index=True, editable=False)), - ('rght', models.PositiveIntegerField(db_index=True, editable=False)), - ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), - ('position', models.PositiveIntegerField(db_index=True, editable=False)), - ('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks', on_delete=models.CASCADE)), - ('previous', models.OneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True, on_delete=models.CASCADE)), - ('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + auto_created=True, + verbose_name="ID", + primary_key=True, + serialize=False, + ), + ), + ("lft", models.PositiveIntegerField(db_index=True, editable=False)), + ("rght", models.PositiveIntegerField(db_index=True, editable=False)), + ("tree_id", models.PositiveIntegerField(db_index=True, editable=False)), + ( + "position", + models.PositiveIntegerField(db_index=True, editable=False), + ), + ( + "playlist", + models.ForeignKey( + to="playlists.Playlist", + related_name="playlist_tracks", + on_delete=models.CASCADE, + ), + ), + ( + "previous", + models.OneToOneField( + null=True, + to="playlists.PlaylistTrack", + related_name="next", + blank=True, + on_delete=models.CASCADE, + ), + ), + ( + "track", + models.ForeignKey( + to="music.Track", + related_name="playlist_tracks", + on_delete=models.CASCADE, + ), + ), ], - options={ - 'ordering': ('-playlist', 'position'), - }, + options={"ordering": ("-playlist", "position")}, ), ] diff --git a/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py b/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py index 23d0a8eab..8245797bf 100644 --- a/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py +++ b/api/funkwhale_api/playlists/migrations/0002_auto_20180316_2217.py @@ -5,18 +5,22 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('playlists', '0001_initial'), - ] + dependencies = [("playlists", "0001_initial")] operations = [ - migrations.RemoveField( - model_name='playlist', - name='is_public', - ), + migrations.RemoveField(model_name="playlist", name="is_public"), migrations.AddField( - model_name='playlist', - name='privacy_level', - field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30), + model_name="playlist", + name="privacy_level", + field=models.CharField( + choices=[ + ("me", "Only me"), + ("followers", "Me and my followers"), + ("instance", "Everyone on my instance, and my followers"), + ("everyone", "Everyone, including people on other instances"), + ], + default="instance", + max_length=30, + ), ), ] diff --git a/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py b/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py index 0284f8f2c..d4d28b9e0 100644 --- a/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py +++ b/api/funkwhale_api/playlists/migrations/0003_auto_20180319_1214.py @@ -6,47 +6,28 @@ import django.utils.timezone class Migration(migrations.Migration): - dependencies = [ - ('playlists', '0002_auto_20180316_2217'), - ] + dependencies = [("playlists", "0002_auto_20180316_2217")] operations = [ migrations.AlterModelOptions( - name='playlisttrack', - options={'ordering': ('-playlist', 'index')}, + name="playlisttrack", options={"ordering": ("-playlist", "index")} ), migrations.AddField( - model_name='playlisttrack', - name='creation_date', + model_name="playlisttrack", + name="creation_date", field=models.DateTimeField(default=django.utils.timezone.now), ), migrations.AddField( - model_name='playlisttrack', - name='index', + model_name="playlisttrack", + name="index", field=models.PositiveIntegerField(null=True), ), - migrations.RemoveField( - model_name='playlisttrack', - name='lft', - ), - migrations.RemoveField( - model_name='playlisttrack', - name='position', - ), - migrations.RemoveField( - model_name='playlisttrack', - name='previous', - ), - migrations.RemoveField( - model_name='playlisttrack', - name='rght', - ), - migrations.RemoveField( - model_name='playlisttrack', - name='tree_id', - ), + migrations.RemoveField(model_name="playlisttrack", name="lft"), + migrations.RemoveField(model_name="playlisttrack", name="position"), + migrations.RemoveField(model_name="playlisttrack", name="previous"), + migrations.RemoveField(model_name="playlisttrack", name="rght"), + migrations.RemoveField(model_name="playlisttrack", name="tree_id"), migrations.AlterUniqueTogether( - name='playlisttrack', - unique_together={('playlist', 'index')}, + name="playlisttrack", unique_together={("playlist", "index")} ), ] diff --git a/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py b/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py index 415b53612..75c42a5c0 100644 --- a/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py +++ b/api/funkwhale_api/playlists/migrations/0004_auto_20180320_1713.py @@ -5,23 +5,18 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('playlists', '0003_auto_20180319_1214'), - ] + dependencies = [("playlists", "0003_auto_20180319_1214")] operations = [ migrations.AddField( - model_name='playlist', - name='modification_date', + model_name="playlist", + name="modification_date", field=models.DateTimeField(auto_now=True), ), migrations.AlterField( - model_name='playlisttrack', - name='index', + model_name="playlisttrack", + name="index", field=models.PositiveIntegerField(blank=True, null=True), ), - migrations.AlterUniqueTogether( - name='playlisttrack', - unique_together=set(), - ), + migrations.AlterUniqueTogether(name="playlisttrack", unique_together=set()), ] diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index f5132e12d..e9df4624d 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -1,27 +1,22 @@ -from django.conf import settings -from django.db import models -from django.db import transaction +from django.db import models, transaction from django.utils import timezone - from rest_framework import exceptions -from funkwhale_api.common import fields -from funkwhale_api.common import preferences +from funkwhale_api.common import fields, preferences class PlaylistQuerySet(models.QuerySet): def with_tracks_count(self): - return self.annotate( - _tracks_count=models.Count('playlist_tracks')) + return self.annotate(_tracks_count=models.Count("playlist_tracks")) class Playlist(models.Model): name = models.CharField(max_length=50) user = models.ForeignKey( - 'users.User', related_name="playlists", on_delete=models.CASCADE) + "users.User", related_name="playlists", on_delete=models.CASCADE + ) creation_date = models.DateTimeField(default=timezone.now) - modification_date = models.DateTimeField( - auto_now=True) + modification_date = models.DateTimeField(auto_now=True) privacy_level = fields.get_privacy_field() objects = PlaylistQuerySet.as_manager() @@ -51,89 +46,91 @@ class Playlist(models.Model): index = total if index > total: - raise exceptions.ValidationError('Index is not continuous') + raise exceptions.ValidationError("Index is not continuous") if index < 0: - raise exceptions.ValidationError('Index must be zero or positive') + raise exceptions.ValidationError("Index must be zero or positive") if move: # we remove the index temporarily, to avoid integrity errors plt.index = None - plt.save(update_fields=['index']) + plt.save(update_fields=["index"]) if index > old_index: # new index is higher than current, we decrement previous tracks - to_update = existing.filter( - index__gt=old_index, index__lte=index) - to_update.update(index=models.F('index') - 1) + to_update = existing.filter(index__gt=old_index, index__lte=index) + to_update.update(index=models.F("index") - 1) if index < old_index: # new index is lower than current, we increment next tracks to_update = existing.filter(index__lt=old_index, index__gte=index) - to_update.update(index=models.F('index') + 1) + to_update.update(index=models.F("index") + 1) else: to_update = existing.filter(index__gte=index) - to_update.update(index=models.F('index') + 1) + to_update.update(index=models.F("index") + 1) plt.index = index - plt.save(update_fields=['index']) - self.save(update_fields=['modification_date']) + plt.save(update_fields=["index"]) + self.save(update_fields=["modification_date"]) return index @transaction.atomic def remove(self, index): existing = self.playlist_tracks.select_for_update() - self.save(update_fields=['modification_date']) + self.save(update_fields=["modification_date"]) to_update = existing.filter(index__gt=index) - return to_update.update(index=models.F('index') - 1) + return to_update.update(index=models.F("index") - 1) @transaction.atomic def insert_many(self, tracks): existing = self.playlist_tracks.select_for_update() now = timezone.now() total = existing.filter(index__isnull=False).count() - max_tracks = preferences.get('playlists__max_tracks') + max_tracks = preferences.get("playlists__max_tracks") if existing.count() + len(tracks) > max_tracks: raise exceptions.ValidationError( - 'Playlist would reach the maximum of {} tracks'.format( - max_tracks)) - self.save(update_fields=['modification_date']) + "Playlist would reach the maximum of {} tracks".format(max_tracks) + ) + self.save(update_fields=["modification_date"]) start = total plts = [ PlaylistTrack( - creation_date=now, playlist=self, track=track, index=start+i) + creation_date=now, playlist=self, track=track, index=start + i + ) for i, track in enumerate(tracks) ] return PlaylistTrack.objects.bulk_create(plts) + class PlaylistTrackQuerySet(models.QuerySet): def for_nested_serialization(self): - return (self.select_related() - .select_related('track__album__artist') - .prefetch_related( - 'track__tags', - 'track__files', - 'track__artist__albums__tracks__tags')) + return ( + self.select_related() + .select_related("track__album__artist") + .prefetch_related( + "track__tags", "track__files", "track__artist__albums__tracks__tags" + ) + ) class PlaylistTrack(models.Model): track = models.ForeignKey( - 'music.Track', - related_name='playlist_tracks', - on_delete=models.CASCADE) + "music.Track", related_name="playlist_tracks", on_delete=models.CASCADE + ) index = models.PositiveIntegerField(null=True, blank=True) playlist = models.ForeignKey( - Playlist, related_name='playlist_tracks', on_delete=models.CASCADE) + Playlist, related_name="playlist_tracks", on_delete=models.CASCADE + ) creation_date = models.DateTimeField(default=timezone.now) objects = PlaylistTrackQuerySet.as_manager() class Meta: - ordering = ('-playlist', 'index') - unique_together = ('playlist', 'index') + ordering = ("-playlist", "index") + unique_together = ("playlist", "index") def delete(self, *args, **kwargs): playlist = self.playlist index = self.index - update_indexes = kwargs.pop('update_indexes', False) + update_indexes = kwargs.pop("update_indexes", False) r = super().delete(*args, **kwargs) if index is not None and update_indexes: playlist.remove(index) diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index 3f01fd689..17cc06b10 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -1,12 +1,11 @@ -from django.conf import settings from django.db import transaction from rest_framework import serializers -from taggit.models import Tag from funkwhale_api.common import preferences from funkwhale_api.music.models import Track from funkwhale_api.music.serializers import TrackSerializer from funkwhale_api.users.serializers import UserBasicSerializer + from . import models @@ -15,42 +14,42 @@ class PlaylistTrackSerializer(serializers.ModelSerializer): class Meta: model = models.PlaylistTrack - fields = ('id', 'track', 'playlist', 'index', 'creation_date') + fields = ("id", "track", "playlist", "index", "creation_date") class PlaylistTrackWriteSerializer(serializers.ModelSerializer): - index = serializers.IntegerField( - required=False, min_value=0, allow_null=True) + index = serializers.IntegerField(required=False, min_value=0, allow_null=True) class Meta: model = models.PlaylistTrack - fields = ('id', 'track', 'playlist', 'index') + fields = ("id", "track", "playlist", "index") def validate_playlist(self, value): - if self.context.get('request'): + if self.context.get("request"): # validate proper ownership on the playlist - if self.context['request'].user != value.user: + if self.context["request"].user != value.user: raise serializers.ValidationError( - 'You do not have the permission to edit this playlist') + "You do not have the permission to edit this playlist" + ) existing = value.playlist_tracks.count() - max_tracks = preferences.get('playlists__max_tracks') + max_tracks = preferences.get("playlists__max_tracks") if existing >= max_tracks: raise serializers.ValidationError( - 'Playlist has reached the maximum of {} tracks'.format( - max_tracks)) + "Playlist has reached the maximum of {} tracks".format(max_tracks) + ) return value @transaction.atomic def create(self, validated_data): - index = validated_data.pop('index', None) + index = validated_data.pop("index", None) instance = super().create(validated_data) instance.playlist.insert(instance, index) return instance @transaction.atomic def update(self, instance, validated_data): - update_index = 'index' in validated_data - index = validated_data.pop('index', None) + update_index = "index" in validated_data + index = validated_data.pop("index", None) super().update(instance, validated_data) if update_index: instance.playlist.insert(instance, index) @@ -71,17 +70,15 @@ class PlaylistSerializer(serializers.ModelSerializer): class Meta: model = models.Playlist fields = ( - 'id', - 'name', - 'tracks_count', - 'user', - 'modification_date', - 'creation_date', - 'privacy_level',) - read_only_fields = [ - 'id', - 'modification_date', - 'creation_date',] + "id", + "name", + "tracks_count", + "user", + "modification_date", + "creation_date", + "privacy_level", + ) + read_only_fields = ["id", "modification_date", "creation_date"] def get_tracks_count(self, obj): try: @@ -93,4 +90,5 @@ class PlaylistSerializer(serializers.ModelSerializer): class PlaylistAddManySerializer(serializers.Serializer): tracks = serializers.PrimaryKeyRelatedField( - many=True, queryset=Track.objects.for_nested_serialization()) + many=True, queryset=Track.objects.for_nested_serialization() + ) diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index 683f90388..d5d19df74 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -1,123 +1,118 @@ -from django.db.models import Count from django.db import transaction - -from rest_framework import exceptions -from rest_framework import generics, mixins, viewsets -from rest_framework import status +from django.db.models import Count +from rest_framework import exceptions, mixins, viewsets from rest_framework.decorators import detail_route -from rest_framework.response import Response from rest_framework.permissions import IsAuthenticatedOrReadOnly +from rest_framework.response import Response -from funkwhale_api.common import permissions -from funkwhale_api.common import fields -from funkwhale_api.music.models import Track +from funkwhale_api.common import fields, permissions + +from . import filters, models, serializers -from . import filters -from . import models -from . import serializers class PlaylistViewSet( - mixins.RetrieveModelMixin, - mixins.CreateModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): serializer_class = serializers.PlaylistSerializer queryset = ( - models.Playlist.objects.all().select_related('user') - .annotate(tracks_count=Count('playlist_tracks')) + models.Playlist.objects.all() + .select_related("user") + .annotate(tracks_count=Count("playlist_tracks")) ) permission_classes = [ permissions.ConditionalAuthentication, permissions.OwnerPermission, IsAuthenticatedOrReadOnly, ] - owner_checks = ['write'] + owner_checks = ["write"] filter_class = filters.PlaylistFilter - ordering_fields = ('id', 'name', 'creation_date', 'modification_date') + ordering_fields = ("id", "name", "creation_date", "modification_date") - @detail_route(methods=['get']) + @detail_route(methods=["get"]) def tracks(self, request, *args, **kwargs): playlist = self.get_object() plts = playlist.playlist_tracks.all().for_nested_serialization() serializer = serializers.PlaylistTrackSerializer(plts, many=True) - data = { - 'count': len(plts), - 'results': serializer.data - } + data = {"count": len(plts), "results": serializer.data} return Response(data, status=200) - @detail_route(methods=['post']) + @detail_route(methods=["post"]) @transaction.atomic def add(self, request, *args, **kwargs): playlist = self.get_object() serializer = serializers.PlaylistAddManySerializer(data=request.data) serializer.is_valid(raise_exception=True) try: - plts = playlist.insert_many(serializer.validated_data['tracks']) + plts = playlist.insert_many(serializer.validated_data["tracks"]) except exceptions.ValidationError as e: - payload = {'playlist': e.detail} + payload = {"playlist": e.detail} return Response(payload, status=400) ids = [p.id for p in plts] - plts = models.PlaylistTrack.objects.filter( - pk__in=ids).order_by('index').for_nested_serialization() + plts = ( + models.PlaylistTrack.objects.filter(pk__in=ids) + .order_by("index") + .for_nested_serialization() + ) serializer = serializers.PlaylistTrackSerializer(plts, many=True) - data = { - 'count': len(plts), - 'results': serializer.data - } + data = {"count": len(plts), "results": serializer.data} return Response(data, status=201) - @detail_route(methods=['delete']) + @detail_route(methods=["delete"]) @transaction.atomic def clear(self, request, *args, **kwargs): playlist = self.get_object() playlist.playlist_tracks.all().delete() - playlist.save(update_fields=['modification_date']) + playlist.save(update_fields=["modification_date"]) return Response(status=204) def get_queryset(self): - return self.queryset.filter( - fields.privacy_level_query(self.request.user)) + return self.queryset.filter(fields.privacy_level_query(self.request.user)) def perform_create(self, serializer): return serializer.save( user=self.request.user, privacy_level=serializer.validated_data.get( - 'privacy_level', self.request.user.privacy_level) + "privacy_level", self.request.user.privacy_level + ), ) class PlaylistTrackViewSet( - mixins.RetrieveModelMixin, - mixins.CreateModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): serializer_class = serializers.PlaylistTrackSerializer - queryset = (models.PlaylistTrack.objects.all().for_nested_serialization()) + queryset = models.PlaylistTrack.objects.all().for_nested_serialization() permission_classes = [ permissions.ConditionalAuthentication, permissions.OwnerPermission, IsAuthenticatedOrReadOnly, ] - owner_field = 'playlist.user' - owner_checks = ['write'] + owner_field = "playlist.user" + owner_checks = ["write"] def get_serializer_class(self): - if self.request.method in ['PUT', 'PATCH', 'DELETE', 'POST']: + if self.request.method in ["PUT", "PATCH", "DELETE", "POST"]: return serializers.PlaylistTrackWriteSerializer return self.serializer_class def get_queryset(self): return self.queryset.filter( fields.privacy_level_query( - self.request.user, - lookup_field='playlist__privacy_level')) + self.request.user, lookup_field="playlist__privacy_level" + ) + ) def perform_destroy(self, instance): instance.delete(update_indexes=True) diff --git a/api/funkwhale_api/providers/acoustid/__init__.py b/api/funkwhale_api/providers/acoustid/__init__.py index 69fe058b3..558a95bb8 100644 --- a/api/funkwhale_api/providers/acoustid/__init__.py +++ b/api/funkwhale_api/providers/acoustid/__init__.py @@ -14,14 +14,14 @@ class Client(object): results = self.match(file_path=file_path) MIN_SCORE_FOR_MATCH = 0.8 try: - rows = results['results'] + rows = results["results"] except KeyError: return for row in rows: - if row['score'] >= MIN_SCORE_FOR_MATCH: + if row["score"] >= MIN_SCORE_FOR_MATCH: return row def get_acoustid_client(): manager = global_preferences_registry.manager() - return Client(api_key=manager['providers_acoustid__api_key']) + return Client(api_key=manager["providers_acoustid__api_key"]) diff --git a/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py b/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py index 33c9643b0..2411de86a 100644 --- a/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py +++ b/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py @@ -1,19 +1,16 @@ from django import forms - -from dynamic_preferences.types import StringPreference, Section from dynamic_preferences.registries import global_preferences_registry +from dynamic_preferences.types import Section, StringPreference -acoustid = Section('providers_acoustid') +acoustid = Section("providers_acoustid") @global_preferences_registry.register class APIKey(StringPreference): section = acoustid - name = 'api_key' - default = '' - verbose_name = 'Acoustid API key' - help_text = 'The API key used to query AcoustID. Get one at https://acoustid.org/new-application.' + name = "api_key" + default = "" + verbose_name = "Acoustid API key" + help_text = "The API key used to query AcoustID. Get one at https://acoustid.org/new-application." widget = forms.PasswordInput - field_kwargs = { - 'required': False, - } + field_kwargs = {"required": False} diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py index 70ff90ffa..de2560d3c 100644 --- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py +++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py @@ -5,192 +5,199 @@ from django.conf import settings from django.core.files import File from django.core.management.base import BaseCommand, CommandError -from funkwhale_api.music import models -from funkwhale_api.music import tasks +from funkwhale_api.music import models, tasks from funkwhale_api.users.models import User class Command(BaseCommand): - help = 'Import audio files mathinc given glob pattern' + help = "Import audio files mathinc given glob pattern" def add_arguments(self, parser): - parser.add_argument('path', type=str) + parser.add_argument("path", type=str) parser.add_argument( - '--recursive', - action='store_true', - dest='recursive', + "--recursive", + action="store_true", + dest="recursive", default=False, - help='Will match the pattern recursively (including subdirectories)', + help="Will match the pattern recursively (including subdirectories)", ) parser.add_argument( - '--username', - dest='username', - help='The username of the user you want to be bound to the import', + "--username", + dest="username", + help="The username of the user you want to be bound to the import", ) parser.add_argument( - '--async', - action='store_true', - dest='async', + "--async", + action="store_true", + dest="async", default=False, - help='Will launch celery tasks for each file to import instead of doing it synchronously and block the CLI', + help="Will launch celery tasks for each file to import instead of doing it synchronously and block the CLI", ) parser.add_argument( - '--exit', '-x', - action='store_true', - dest='exit_on_failure', + "--exit", + "-x", + action="store_true", + dest="exit_on_failure", default=False, - help='Use this flag to disable error catching', + help="Use this flag to disable error catching", ) parser.add_argument( - '--in-place', '-i', - action='store_true', - dest='in_place', + "--in-place", + "-i", + action="store_true", + dest="in_place", default=False, help=( - 'Import files without duplicating them into the media directory.' - 'For in-place import to work, the music files must be readable' - 'by the web-server and funkwhale api and celeryworker processes.' - 'You may want to use this if you have a big music library to ' - 'import and not much disk space available.' - ) + "Import files without duplicating them into the media directory." + "For in-place import to work, the music files must be readable" + "by the web-server and funkwhale api and celeryworker processes." + "You may want to use this if you have a big music library to " + "import and not much disk space available." + ), ) parser.add_argument( - '--noinput', '--no-input', action='store_false', dest='interactive', + "--noinput", + "--no-input", + action="store_false", + dest="interactive", help="Do NOT prompt the user for input of any kind.", ) def handle(self, *args, **options): glob_kwargs = {} - if options['recursive']: - glob_kwargs['recursive'] = True + if options["recursive"]: + glob_kwargs["recursive"] = True try: - matching = sorted(glob.glob(options['path'], **glob_kwargs)) + matching = sorted(glob.glob(options["path"], **glob_kwargs)) except TypeError: - raise Exception('You need Python 3.5 to use the --recursive flag') + raise Exception("You need Python 3.5 to use the --recursive flag") - if options['in_place']: + if options["in_place"]: self.stdout.write( - 'Checking imported paths against settings.MUSIC_DIRECTORY_PATH') + "Checking imported paths against settings.MUSIC_DIRECTORY_PATH" + ) p = settings.MUSIC_DIRECTORY_PATH if not p: raise CommandError( - 'Importing in-place requires setting the ' - 'MUSIC_DIRECTORY_PATH variable') + "Importing in-place requires setting the " + "MUSIC_DIRECTORY_PATH variable" + ) for m in matching: if not m.startswith(p): raise CommandError( - 'Importing in-place only works if importing' - 'from {} (MUSIC_DIRECTORY_PATH), as this directory' - 'needs to be accessible by the webserver.' - 'Culprit: {}'.format(p, m)) + "Importing in-place only works if importing" + "from {} (MUSIC_DIRECTORY_PATH), as this directory" + "needs to be accessible by the webserver." + "Culprit: {}".format(p, m) + ) if not matching: - raise CommandError('No file matching pattern, aborting') + raise CommandError("No file matching pattern, aborting") user = None - if options['username']: + if options["username"]: try: - user = User.objects.get(username=options['username']) + user = User.objects.get(username=options["username"]) except User.DoesNotExist: - raise CommandError('Invalid username') + raise CommandError("Invalid username") else: # we bind the import to the first registered superuser try: - user = User.objects.filter(is_superuser=True).order_by('pk').first() + user = User.objects.filter(is_superuser=True).order_by("pk").first() assert user is not None except AssertionError: raise CommandError( - 'No superuser available, please provide a --username') + "No superuser available, please provide a --username" + ) filtered = self.filter_matching(matching, options) - self.stdout.write('Import summary:') - self.stdout.write('- {} files found matching this pattern: {}'.format( - len(matching), options['path'])) - self.stdout.write('- {} files already found in database'.format( - len(filtered['skipped']))) - self.stdout.write('- {} new files'.format( - len(filtered['new']))) + self.stdout.write("Import summary:") + self.stdout.write( + "- {} files found matching this pattern: {}".format( + len(matching), options["path"] + ) + ) + self.stdout.write( + "- {} files already found in database".format(len(filtered["skipped"])) + ) + self.stdout.write("- {} new files".format(len(filtered["new"]))) - self.stdout.write('Selected options: {}'.format(', '.join([ - 'in place' if options['in_place'] else 'copy music files', - ]))) - if len(filtered['new']) == 0: - self.stdout.write('Nothing new to import, exiting') + self.stdout.write( + "Selected options: {}".format( + ", ".join(["in place" if options["in_place"] else "copy music files"]) + ) + ) + if len(filtered["new"]) == 0: + self.stdout.write("Nothing new to import, exiting") return - if options['interactive']: + if options["interactive"]: message = ( - 'Are you sure you want to do this?\n\n' + "Are you sure you want to do this?\n\n" "Type 'yes' to continue, or 'no' to cancel: " ) - if input(''.join(message)) != 'yes': + if input("".join(message)) != "yes": raise CommandError("Import cancelled.") - batch, errors = self.do_import( - filtered['new'], user=user, options=options) - message = 'Successfully imported {} tracks' - if options['async']: - message = 'Successfully launched import for {} tracks' + batch, errors = self.do_import(filtered["new"], user=user, options=options) + message = "Successfully imported {} tracks" + if options["async"]: + message = "Successfully launched import for {} tracks" - self.stdout.write(message.format(len(filtered['new']))) + self.stdout.write(message.format(len(filtered["new"]))) if len(errors) > 0: - self.stderr.write( - '{} tracks could not be imported:'.format(len(errors))) + self.stderr.write("{} tracks could not be imported:".format(len(errors))) for path, error in errors: - self.stderr.write('- {}: {}'.format(path, error)) + self.stderr.write("- {}: {}".format(path, error)) self.stdout.write( - "For details, please refer to import batch #{}".format(batch.pk)) + "For details, please refer to import batch #{}".format(batch.pk) + ) def filter_matching(self, matching, options): - sources = ['file://{}'.format(p) for p in matching] + sources = ["file://{}".format(p) for p in matching] # we skip reimport for path that are already found # as a TrackFile.source existing = models.TrackFile.objects.filter(source__in=sources) - existing = existing.values_list('source', flat=True) - existing = set([p.replace('file://', '', 1) for p in existing]) + existing = existing.values_list("source", flat=True) + existing = set([p.replace("file://", "", 1) for p in existing]) skipped = set(matching) & existing result = { - 'initial': matching, - 'skipped': list(sorted(skipped)), - 'new': list(sorted(set(matching) - skipped)), + "initial": matching, + "skipped": list(sorted(skipped)), + "new": list(sorted(set(matching) - skipped)), } return result def do_import(self, paths, user, options): - message = '{i}/{total} Importing {path}...' - if options['async']: - message = '{i}/{total} Launching import for {path}...' + message = "{i}/{total} Importing {path}..." + if options["async"]: + message = "{i}/{total} Launching import for {path}..." # we create an import batch binded to the user - async = options['async'] + async = options["async"] import_handler = tasks.import_job_run.delay if async else tasks.import_job_run - batch = user.imports.create(source='shell') - total = len(paths) + batch = user.imports.create(source="shell") errors = [] for i, path in list(enumerate(paths)): try: - self.stdout.write( - message.format(path=path, i=i+1, total=len(paths))) + self.stdout.write(message.format(path=path, i=i + 1, total=len(paths))) self.import_file(path, batch, import_handler, options) except Exception as e: - if options['exit_on_failure']: + if options["exit_on_failure"]: raise - m = 'Error while importing {}: {} {}'.format( - path, e.__class__.__name__, e) + m = "Error while importing {}: {} {}".format( + path, e.__class__.__name__, e + ) self.stderr.write(m) - errors.append((path, '{} {}'.format(e.__class__.__name__, e))) + errors.append((path, "{} {}".format(e.__class__.__name__, e))) return batch, errors def import_file(self, path, batch, import_handler, options): - job = batch.jobs.create( - source='file://' + path, - ) - if not options['in_place']: + job = batch.jobs.create(source="file://" + path) + if not options["in_place"]: name = os.path.basename(path) - with open(path, 'rb') as f: + with open(path, "rb") as f: job.audio_file.save(name, File(f)) job.save() - import_handler( - import_job_id=job.pk, - use_acoustid=False) + import_handler(import_job_id=job.pk, use_acoustid=False) diff --git a/api/funkwhale_api/providers/audiofile/tasks.py b/api/funkwhale_api/providers/audiofile/tasks.py index fb6306735..ee486345a 100644 --- a/api/funkwhale_api/providers/audiofile/tasks.py +++ b/api/funkwhale_api/providers/audiofile/tasks.py @@ -1,95 +1,45 @@ -import acoustid -import os -import datetime -from django.core.files import File from django.db import transaction -from funkwhale_api.taskapp import celery -from funkwhale_api.providers.acoustid import get_acoustid_client -from funkwhale_api.music import models, metadata +from funkwhale_api.music import metadata, models @transaction.atomic def import_track_data_from_path(path): data = metadata.Metadata(path) album = None - track_mbid = data.get('musicbrainz_recordingid', None) - album_mbid = data.get('musicbrainz_albumid', None) + track_mbid = data.get("musicbrainz_recordingid", None) + album_mbid = data.get("musicbrainz_albumid", None) if album_mbid and track_mbid: # to gain performance and avoid additional mb lookups, # we import from the release data, which is already cached - return models.Track.get_or_create_from_release( - album_mbid, track_mbid)[0] + return models.Track.get_or_create_from_release(album_mbid, track_mbid)[0] elif track_mbid: return models.Track.get_or_create_from_api(track_mbid)[0] elif album_mbid: album = models.Album.get_or_create_from_api(album_mbid)[0] artist = album.artist if album else None - artist_mbid = data.get('musicbrainz_artistid', None) + artist_mbid = data.get("musicbrainz_artistid", None) if not artist: if artist_mbid: artist = models.Artist.get_or_create_from_api(artist_mbid)[0] else: artist = models.Artist.objects.get_or_create( - name__iexact=data.get('artist'), - defaults={ - 'name': data.get('artist'), - }, + name__iexact=data.get("artist"), defaults={"name": data.get("artist")} )[0] - release_date = data.get('date', default=None) + release_date = data.get("date", default=None) if not album: album = models.Album.objects.get_or_create( - title__iexact=data.get('album'), + title__iexact=data.get("album"), artist=artist, - defaults={ - 'title': data.get('album'), - 'release_date': release_date, - }, + defaults={"title": data.get("album"), "release_date": release_date}, )[0] - position = data.get('track_number', default=None) + position = data.get("track_number", default=None) track = models.Track.objects.get_or_create( - title__iexact=data.get('title'), + title__iexact=data.get("title"), album=album, - defaults={ - 'title': data.get('title'), - 'position': position, - }, + defaults={"title": data.get("title"), "position": position}, )[0] return track - - -def import_metadata_with_musicbrainz(path): - pass - - -@celery.app.task(name='audiofile.from_path') -def from_path(path): - acoustid_track_id = None - try: - client = get_acoustid_client() - result = client.get_best_match(path) - acoustid_track_id = result['id'] - except acoustid.WebServiceError: - track = import_track_data_from_path(path) - except (TypeError, KeyError): - track = import_metadata_without_musicbrainz(path) - else: - track, created = models.Track.get_or_create_from_api( - mbid=result['recordings'][0]['id'] - ) - - if track.files.count() > 0: - raise ValueError('File already exists for track {}'.format(track.pk)) - - track_file = models.TrackFile( - track=track, acoustid_track_id=acoustid_track_id) - track_file.audio_file.save( - os.path.basename(path), - File(open(path, 'rb')) - ) - track_file.save() - - return track_file diff --git a/api/funkwhale_api/providers/urls.py b/api/funkwhale_api/providers/urls.py index 10975da53..55a1193f5 100644 --- a/api/funkwhale_api/providers/urls.py +++ b/api/funkwhale_api/providers/urls.py @@ -1,11 +1,16 @@ from django.conf.urls import include, url -from funkwhale_api.music import views urlpatterns = [ - url(r'^youtube/', include( - ('funkwhale_api.providers.youtube.urls', 'youtube'), - namespace='youtube')), - url(r'^musicbrainz/', include( - ('funkwhale_api.musicbrainz.urls', 'musicbrainz'), - namespace='musicbrainz')), + url( + r"^youtube/", + include( + ("funkwhale_api.providers.youtube.urls", "youtube"), namespace="youtube" + ), + ), + url( + r"^musicbrainz/", + include( + ("funkwhale_api.musicbrainz.urls", "musicbrainz"), namespace="musicbrainz" + ), + ), ] diff --git a/api/funkwhale_api/providers/youtube/client.py b/api/funkwhale_api/providers/youtube/client.py index 792e501d7..2235fcdc8 100644 --- a/api/funkwhale_api/providers/youtube/client.py +++ b/api/funkwhale_api/providers/youtube/client.py @@ -1,15 +1,11 @@ import threading from apiclient.discovery import build -from apiclient.errors import HttpError -from oauth2client.tools import argparser - -from dynamic_preferences.registries import ( - global_preferences_registry as registry) +from dynamic_preferences.registries import global_preferences_registry as registry YOUTUBE_API_SERVICE_NAME = "youtube" YOUTUBE_API_VERSION = "v3" -VIDEO_BASE_URL = 'https://www.youtube.com/watch?v={0}' +VIDEO_BASE_URL = "https://www.youtube.com/watch?v={0}" def _do_search(query): @@ -17,23 +13,21 @@ def _do_search(query): youtube = build( YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, - developerKey=manager['providers_youtube__api_key']) + developerKey=manager["providers_youtube__api_key"], + ) - return youtube.search().list( - q=query, - part="id,snippet", - maxResults=25 - ).execute() + return youtube.search().list(q=query, part="id,snippet", maxResults=25).execute() class Client(object): - def search(self, query): search_response = _do_search(query) videos = [] for search_result in search_response.get("items", []): if search_result["id"]["kind"] == "youtube#video": - search_result['full_url'] = VIDEO_BASE_URL.format(search_result["id"]['videoId']) + search_result["full_url"] = VIDEO_BASE_URL.format( + search_result["id"]["videoId"] + ) videos.append(search_result) return videos @@ -44,7 +38,7 @@ class Client(object): results[key] = self.search(query) threads = [ - threading.Thread(target=search, args=(key, query,)) + threading.Thread(target=search, args=(key, query)) for key, query in queries.items() ] for thread in threads: @@ -71,16 +65,16 @@ class Client(object): } """ return { - 'id': result['id']['videoId'], - 'url': 'https://www.youtube.com/watch?v={}'.format( - result['id']['videoId']), - 'type': result['id']['kind'], - 'title': result['snippet']['title'], - 'description': result['snippet']['description'], - 'channelId': result['snippet']['channelId'], - 'channelTitle': result['snippet']['channelTitle'], - 'publishedAt': result['snippet']['publishedAt'], - 'cover': result['snippet']['thumbnails']['high']['url'], + "id": result["id"]["videoId"], + "url": "https://www.youtube.com/watch?v={}".format(result["id"]["videoId"]), + "type": result["id"]["kind"], + "title": result["snippet"]["title"], + "description": result["snippet"]["description"], + "channelId": result["snippet"]["channelId"], + "channelTitle": result["snippet"]["channelTitle"], + "publishedAt": result["snippet"]["publishedAt"], + "cover": result["snippet"]["thumbnails"]["high"]["url"], } + client = Client() diff --git a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py index ac5fc4bde..2d950eb6b 100644 --- a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py +++ b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py @@ -1,19 +1,16 @@ from django import forms - -from dynamic_preferences.types import StringPreference, Section from dynamic_preferences.registries import global_preferences_registry +from dynamic_preferences.types import Section, StringPreference -youtube = Section('providers_youtube') +youtube = Section("providers_youtube") @global_preferences_registry.register class APIKey(StringPreference): section = youtube - name = 'api_key' - default = 'CHANGEME' - verbose_name = 'YouTube API key' - help_text = 'The API key used to query YouTube. Get one at https://console.developers.google.com/.' + name = "api_key" + default = "CHANGEME" + verbose_name = "YouTube API key" + help_text = "The API key used to query YouTube. Get one at https://console.developers.google.com/." widget = forms.PasswordInput - field_kwargs = { - 'required': False, - } + field_kwargs = {"required": False} diff --git a/api/funkwhale_api/providers/youtube/urls.py b/api/funkwhale_api/providers/youtube/urls.py index 243d2b852..d9687ac9f 100644 --- a/api/funkwhale_api/providers/youtube/urls.py +++ b/api/funkwhale_api/providers/youtube/urls.py @@ -1,8 +1,8 @@ -from django.conf.urls import include, url +from django.conf.urls import url + from .views import APISearch, APISearchs - urlpatterns = [ - url(r'^search/$', APISearch.as_view(), name='search'), - url(r'^searchs/$', APISearchs.as_view(), name='searchs'), + url(r"^search/$", APISearch.as_view(), name="search"), + url(r"^searchs/$", APISearchs.as_view(), name="searchs"), ] diff --git a/api/funkwhale_api/providers/youtube/views.py b/api/funkwhale_api/providers/youtube/views.py index 989b33090..5e1982f48 100644 --- a/api/funkwhale_api/providers/youtube/views.py +++ b/api/funkwhale_api/providers/youtube/views.py @@ -1,5 +1,6 @@ -from rest_framework.views import APIView from rest_framework.response import Response +from rest_framework.views import APIView + from funkwhale_api.common.permissions import ConditionalAuthentication from .client import client @@ -9,11 +10,8 @@ class APISearch(APIView): permission_classes = [ConditionalAuthentication] def get(self, request, *args, **kwargs): - results = client.search(request.GET['query']) - return Response([ - client.to_funkwhale(result) - for result in results - ]) + results = client.search(request.GET["query"]) + return Response([client.to_funkwhale(result) for result in results]) class APISearchs(APIView): @@ -21,10 +19,9 @@ class APISearchs(APIView): def post(self, request, *args, **kwargs): results = client.search_multiple(request.data) - return Response({ - key: [ - client.to_funkwhale(result) - for result in group - ] - for key, group in results.items() - }) + return Response( + { + key: [client.to_funkwhale(result) for result in group] + for key, group in results.items() + } + ) diff --git a/api/funkwhale_api/radios/__init__.py b/api/funkwhale_api/radios/__init__.py index 1258181b5..e69de29bb 100644 --- a/api/funkwhale_api/radios/__init__.py +++ b/api/funkwhale_api/radios/__init__.py @@ -1 +0,0 @@ -from .registries import registry diff --git a/api/funkwhale_api/radios/admin.py b/api/funkwhale_api/radios/admin.py index 6d5abadaf..187950aeb 100644 --- a/api/funkwhale_api/radios/admin.py +++ b/api/funkwhale_api/radios/admin.py @@ -5,44 +5,28 @@ from . import models @admin.register(models.Radio) class RadioAdmin(admin.ModelAdmin): - list_display = [ - 'user', 'name', 'is_public', 'creation_date', 'config'] - list_select_related = [ - 'user', - ] - list_filter = [ - 'is_public', - ] - search_fields = ['name', 'description'] + list_display = ["user", "name", "is_public", "creation_date", "config"] + list_select_related = ["user"] + list_filter = ["is_public"] + search_fields = ["name", "description"] @admin.register(models.RadioSession) class RadioSessionAdmin(admin.ModelAdmin): list_display = [ - 'user', - 'custom_radio', - 'radio_type', - 'creation_date', - 'related_object'] + "user", + "custom_radio", + "radio_type", + "creation_date", + "related_object", + ] - list_select_related = [ - 'user', - 'custom_radio' - ] - list_filter = [ - 'radio_type', - ] + list_select_related = ["user", "custom_radio"] + list_filter = ["radio_type"] @admin.register(models.RadioSessionTrack) class RadioSessionTrackAdmin(admin.ModelAdmin): - list_display = [ - 'id', - 'session', - 'position', - 'track',] + list_display = ["id", "session", "position", "track"] - list_select_related = [ - 'track', - 'session' - ] + list_select_related = ["track", "session"] diff --git a/api/funkwhale_api/radios/factories.py b/api/funkwhale_api/radios/factories.py index 6a80323be..a83c53737 100644 --- a/api/funkwhale_api/radios/factories.py +++ b/api/funkwhale_api/radios/factories.py @@ -6,13 +6,13 @@ from funkwhale_api.users.factories import UserFactory @registry.register class RadioFactory(factory.django.DjangoModelFactory): - name = factory.Faker('name') - description = factory.Faker('paragraphs') + name = factory.Faker("name") + description = factory.Faker("paragraphs") user = factory.SubFactory(UserFactory) config = [] class Meta: - model = 'radios.Radio' + model = "radios.Radio" @registry.register @@ -20,15 +20,16 @@ class RadioSessionFactory(factory.django.DjangoModelFactory): user = factory.SubFactory(UserFactory) class Meta: - model = 'radios.RadioSession' + model = "radios.RadioSession" -@registry.register(name='radios.CustomRadioSession') -class RadioSessionFactory(factory.django.DjangoModelFactory): +@registry.register(name="radios.CustomRadioSession") +class CustomRadioSessionFactory(factory.django.DjangoModelFactory): user = factory.SubFactory(UserFactory) - radio_type = 'custom' + radio_type = "custom" custom_radio = factory.SubFactory( - RadioFactory, user=factory.SelfAttribute('..user')) + RadioFactory, user=factory.SelfAttribute("..user") + ) class Meta: - model = 'radios.RadioSession' + model = "radios.RadioSession" diff --git a/api/funkwhale_api/radios/filters.py b/api/funkwhale_api/radios/filters.py index d0d338d66..810673bd6 100644 --- a/api/funkwhale_api/radios/filters.py +++ b/api/funkwhale_api/radios/filters.py @@ -1,17 +1,14 @@ import collections +import persisting_theory from django.core.exceptions import ValidationError from django.db.models import Q from django.urls import reverse_lazy -import persisting_theory - from funkwhale_api.music import models -from funkwhale_api.taskapp.celery import require_instance class RadioFilterRegistry(persisting_theory.Registry): - def prepare_data(self, data): return data() @@ -20,31 +17,27 @@ class RadioFilterRegistry(persisting_theory.Registry): @property def exposed_filters(self): - return [ - f for f in self.values() if f.expose_in_api - ] + return [f for f in self.values() if f.expose_in_api] registry = RadioFilterRegistry() def run(filters, **kwargs): - candidates = kwargs.pop('candidates', models.Track.objects.all()) + candidates = kwargs.pop("candidates", models.Track.objects.all()) final_query = None - final_query = registry['group'].get_query( - candidates, filters=filters, **kwargs) + final_query = registry["group"].get_query(candidates, filters=filters, **kwargs) if final_query: candidates = candidates.filter(final_query) - return candidates.order_by('pk') + return candidates.order_by("pk") def validate(filter_config): try: - f = registry[filter_config['type']] + f = registry[filter_config["type"]] except KeyError: - raise ValidationError( - 'Invalid type "{}"'.format(filter_config['type'])) + raise ValidationError('Invalid type "{}"'.format(filter_config["type"])) f.validate(filter_config) return True @@ -53,28 +46,22 @@ def test(filter_config, **kwargs): """ Run validation and also gather the candidates for the given config """ - data = { - 'errors': [], - 'candidates': { - 'count': None, - 'sample': None, - } - } + data = {"errors": [], "candidates": {"count": None, "sample": None}} try: validate(filter_config) except ValidationError as e: - data['errors'] = [e.message] + data["errors"] = [e.message] return data candidates = run([filter_config], **kwargs) - data['candidates']['count'] = candidates.count() - data['candidates']['sample'] = candidates[:10] + data["candidates"]["count"] = candidates.count() + data["candidates"]["sample"] = candidates[:10] return data def clean_config(filter_config): - f = registry[filter_config['type']] + f = registry[filter_config["type"]] return f.clean_config(filter_config) @@ -91,74 +78,75 @@ class RadioFilter(object): return filter_config def validate(self, config): - operator = config.get('operator', 'and') + operator = config.get("operator", "and") try: - assert operator in ['or', 'and'] + assert operator in ["or", "and"] except AssertionError: - raise ValidationError( - 'Invalid operator "{}"'.format(config['operator'])) + raise ValidationError('Invalid operator "{}"'.format(config["operator"])) @registry.register class GroupFilter(RadioFilter): - code = 'group' + code = "group" expose_in_api = False + def get_query(self, candidates, filters, **kwargs): if not filters: return final_query = None for filter_config in filters: - f = registry[filter_config['type']] + f = registry[filter_config["type"]] conf = collections.ChainMap(filter_config, kwargs) query = f.get_query(candidates, **conf) - if filter_config.get('not', False): + if filter_config.get("not", False): query = ~query if not final_query: final_query = query else: - operator = filter_config.get('operator', 'and') - if operator == 'and': + operator = filter_config.get("operator", "and") + if operator == "and": final_query &= query - elif operator == 'or': + elif operator == "or": final_query |= query else: - raise ValueError( - 'Invalid query operator "{}"'.format(operator)) + raise ValueError('Invalid query operator "{}"'.format(operator)) return final_query def validate(self, config): super().validate(config) - for fc in config['filters']: - registry[fc['type']].validate(fc) + for fc in config["filters"]: + registry[fc["type"]].validate(fc) @registry.register class ArtistFilter(RadioFilter): - code = 'artist' - label = 'Artist' - help_text = 'Select tracks for a given artist' + code = "artist" + label = "Artist" + help_text = "Select tracks for a given artist" fields = [ { - 'name': 'ids', - 'type': 'list', - 'subtype': 'number', - 'autocomplete': reverse_lazy('api:v1:artists-list'), - 'autocomplete_qs': 'q={query}', - 'autocomplete_fields': {'name': 'name', 'value': 'id'}, - 'label': 'Artist', - 'placeholder': 'Select artists' + "name": "ids", + "type": "list", + "subtype": "number", + "autocomplete": reverse_lazy("api:v1:artists-list"), + "autocomplete_qs": "q={query}", + "autocomplete_fields": {"name": "name", "value": "id"}, + "label": "Artist", + "placeholder": "Select artists", } ] def clean_config(self, filter_config): filter_config = super().clean_config(filter_config) - filter_config['ids'] = sorted(filter_config['ids']) - names = models.Artist.objects.filter( - pk__in=filter_config['ids'] - ).order_by('id').values_list('name', flat=True) - filter_config['names'] = list(names) + filter_config["ids"] = sorted(filter_config["ids"]) + names = ( + models.Artist.objects.filter(pk__in=filter_config["ids"]) + .order_by("id") + .values_list("name", flat=True) + ) + filter_config["names"] = list(names) return filter_config def get_query(self, candidates, ids, **kwargs): @@ -167,35 +155,38 @@ class ArtistFilter(RadioFilter): def validate(self, config): super().validate(config) try: - pks = models.Artist.objects.filter( - pk__in=config['ids']).values_list('pk', flat=True) - diff = set(config['ids']) - set(pks) + pks = models.Artist.objects.filter(pk__in=config["ids"]).values_list( + "pk", flat=True + ) + diff = set(config["ids"]) - set(pks) assert len(diff) == 0 except KeyError: - raise ValidationError('You must provide an id') + raise ValidationError("You must provide an id") except AssertionError: - raise ValidationError( - 'No artist matching ids "{}"'.format(diff)) + raise ValidationError('No artist matching ids "{}"'.format(diff)) @registry.register class TagFilter(RadioFilter): - code = 'tag' + code = "tag" fields = [ { - 'name': 'names', - 'type': 'list', - 'subtype': 'string', - 'autocomplete': reverse_lazy('api:v1:tags-list'), - 'autocomplete_qs': '', - 'autocomplete_fields': {'remoteValues': 'results', 'name': 'name', 'value': 'slug'}, - 'autocomplete_qs': 'query={query}', - 'label': 'Tags', - 'placeholder': 'Select tags' + "name": "names", + "type": "list", + "subtype": "string", + "autocomplete": reverse_lazy("api:v1:tags-list"), + "autocomplete_fields": { + "remoteValues": "results", + "name": "name", + "value": "slug", + }, + "autocomplete_qs": "query={query}", + "label": "Tags", + "placeholder": "Select tags", } ] - help_text = 'Select tracks with a given tag' - label = 'Tag' + help_text = "Select tracks with a given tag" + label = "Tag" def get_query(self, candidates, names, **kwargs): return Q(tags__slug__in=names) diff --git a/api/funkwhale_api/radios/filtersets.py b/api/funkwhale_api/radios/filtersets.py index 49f471373..d8d7c9ed0 100644 --- a/api/funkwhale_api/radios/filtersets.py +++ b/api/funkwhale_api/radios/filtersets.py @@ -4,9 +4,6 @@ from . import models class RadioFilter(django_filters.FilterSet): - class Meta: model = models.Radio - fields = { - 'name': ['exact', 'iexact', 'startswith', 'icontains'] - } + fields = {"name": ["exact", "iexact", "startswith", "icontains"]} diff --git a/api/funkwhale_api/radios/migrations/0001_initial.py b/api/funkwhale_api/radios/migrations/0001_initial.py index 46faf749e..912da7be3 100644 --- a/api/funkwhale_api/radios/migrations/0001_initial.py +++ b/api/funkwhale_api/radios/migrations/0001_initial.py @@ -10,33 +10,72 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('music', '0004_track_tags'), + ("music", "0004_track_tags"), ] operations = [ migrations.CreateModel( - name='RadioSession', + name="RadioSession", fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), - ('radio_type', models.CharField(max_length=50)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('user', models.ForeignKey(related_name='radio_sessions', blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + primary_key=True, + serialize=False, + auto_created=True, + ), + ), + ("radio_type", models.CharField(max_length=50)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "user", + models.ForeignKey( + related_name="radio_sessions", + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + on_delete=models.CASCADE, + ), + ), ], ), migrations.CreateModel( - name='RadioSessionTrack', + name="RadioSessionTrack", fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), - ('position', models.IntegerField(default=1)), - ('session', models.ForeignKey(to='radios.RadioSession', related_name='session_tracks', on_delete=models.CASCADE)), - ('track', models.ForeignKey(to='music.Track', related_name='radio_session_tracks', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + primary_key=True, + serialize=False, + auto_created=True, + ), + ), + ("position", models.IntegerField(default=1)), + ( + "session", + models.ForeignKey( + to="radios.RadioSession", + related_name="session_tracks", + on_delete=models.CASCADE, + ), + ), + ( + "track", + models.ForeignKey( + to="music.Track", + related_name="radio_session_tracks", + on_delete=models.CASCADE, + ), + ), ], - options={ - 'ordering': ('session', 'position'), - }, + options={"ordering": ("session", "position")}, ), migrations.AlterUniqueTogether( - name='radiosessiontrack', - unique_together=set([('session', 'position')]), + name="radiosessiontrack", unique_together=set([("session", "position")]) ), ] diff --git a/api/funkwhale_api/radios/migrations/0002_radiosession_session_key.py b/api/funkwhale_api/radios/migrations/0002_radiosession_session_key.py index a903ae3ea..6c206aa62 100644 --- a/api/funkwhale_api/radios/migrations/0002_radiosession_session_key.py +++ b/api/funkwhale_api/radios/migrations/0002_radiosession_session_key.py @@ -6,14 +6,12 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('radios', '0001_initial'), - ] + dependencies = [("radios", "0001_initial")] operations = [ migrations.AddField( - model_name='radiosession', - name='session_key', + model_name="radiosession", + name="session_key", field=models.CharField(null=True, blank=True, max_length=100), - ), + ) ] diff --git a/api/funkwhale_api/radios/migrations/0003_auto_20160521_1708.py b/api/funkwhale_api/radios/migrations/0003_auto_20160521_1708.py index 7c70abc2e..2af084a87 100644 --- a/api/funkwhale_api/radios/migrations/0003_auto_20160521_1708.py +++ b/api/funkwhale_api/radios/migrations/0003_auto_20160521_1708.py @@ -7,19 +7,24 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('radios', '0002_radiosession_session_key'), + ("contenttypes", "0002_remove_content_type_name"), + ("radios", "0002_radiosession_session_key"), ] operations = [ migrations.AddField( - model_name='radiosession', - name='related_object_content_type', - field=models.ForeignKey(null=True, to='contenttypes.ContentType', blank=True, on_delete=models.CASCADE), + model_name="radiosession", + name="related_object_content_type", + field=models.ForeignKey( + null=True, + to="contenttypes.ContentType", + blank=True, + on_delete=models.CASCADE, + ), ), migrations.AddField( - model_name='radiosession', - name='related_object_id', + model_name="radiosession", + name="related_object_id", field=models.PositiveIntegerField(blank=True, null=True), ), ] diff --git a/api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py b/api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py index fc768b303..72f2a7d31 100644 --- a/api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py +++ b/api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py @@ -11,26 +11,52 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('radios', '0003_auto_20160521_1708'), + ("radios", "0003_auto_20160521_1708"), ] operations = [ migrations.CreateModel( - name='Radio', + name="Radio", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('description', models.TextField(blank=True)), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('is_public', models.BooleanField(default=False)), - ('version', models.PositiveIntegerField(default=0)), - ('config', django.contrib.postgres.fields.jsonb.JSONField()), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='radios', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("description", models.TextField(blank=True)), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("is_public", models.BooleanField(default=False)), + ("version", models.PositiveIntegerField(default=0)), + ("config", django.contrib.postgres.fields.jsonb.JSONField()), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="radios", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.AddField( - model_name='radiosession', - name='custom_radio', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='radios.Radio'), + model_name="radiosession", + name="custom_radio", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sessions", + to="radios.Radio", + ), ), ] diff --git a/api/funkwhale_api/radios/models.py b/api/funkwhale_api/radios/models.py index 8758abc61..d0c3d1716 100644 --- a/api/funkwhale_api/radios/models.py +++ b/api/funkwhale_api/radios/models.py @@ -1,10 +1,9 @@ -from django.db import models -from django.utils import timezone -from django.core.exceptions import ValidationError -from django.contrib.postgres.fields import JSONField from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import JSONField from django.core.serializers.json import DjangoJSONEncoder +from django.db import models +from django.utils import timezone from funkwhale_api.music.models import Track @@ -14,11 +13,12 @@ from . import filters class Radio(models.Model): CONFIG_VERSION = 0 user = models.ForeignKey( - 'users.User', - related_name='radios', + "users.User", + related_name="radios", null=True, blank=True, - on_delete=models.CASCADE) + on_delete=models.CASCADE, + ) name = models.CharField(max_length=100) description = models.TextField(blank=True) creation_date = models.DateTimeField(default=timezone.now) @@ -32,27 +32,25 @@ class Radio(models.Model): class RadioSession(models.Model): user = models.ForeignKey( - 'users.User', - related_name='radio_sessions', + "users.User", + related_name="radio_sessions", null=True, blank=True, - on_delete=models.CASCADE) + on_delete=models.CASCADE, + ) session_key = models.CharField(max_length=100, null=True, blank=True) radio_type = models.CharField(max_length=50) custom_radio = models.ForeignKey( - Radio, - related_name='sessions', - null=True, - blank=True, - on_delete=models.CASCADE) + Radio, related_name="sessions", null=True, blank=True, on_delete=models.CASCADE + ) creation_date = models.DateTimeField(default=timezone.now) related_object_content_type = models.ForeignKey( - ContentType, - blank=True, - null=True, - on_delete=models.CASCADE) + ContentType, blank=True, null=True, on_delete=models.CASCADE + ) related_object_id = models.PositiveIntegerField(blank=True, null=True) - related_object = GenericForeignKey('related_object_content_type', 'related_object_id') + related_object = GenericForeignKey( + "related_object_content_type", "related_object_id" + ) def save(self, **kwargs): self.radio.clean(self) @@ -62,31 +60,35 @@ class RadioSession(models.Model): def next_position(self): next_position = 1 - last_session_track = self.session_tracks.all().order_by('-position').first() + last_session_track = self.session_tracks.all().order_by("-position").first() if last_session_track: next_position = last_session_track.position + 1 return next_position def add(self, track): - new_session_track = RadioSessionTrack.objects.create(track=track, session=self, position=self.next_position) + new_session_track = RadioSessionTrack.objects.create( + track=track, session=self, position=self.next_position + ) return new_session_track @property def radio(self): from .registries import registry - from . import radios + return registry[self.radio_type](session=self) class RadioSessionTrack(models.Model): session = models.ForeignKey( - RadioSession, related_name='session_tracks', on_delete=models.CASCADE) + RadioSession, related_name="session_tracks", on_delete=models.CASCADE + ) position = models.IntegerField(default=1) track = models.ForeignKey( - Track, related_name='radio_session_tracks', on_delete=models.CASCADE) + Track, related_name="radio_session_tracks", on_delete=models.CASCADE + ) class Meta: - ordering = ('session', 'position') - unique_together = ('session', 'position') + ordering = ("session", "position") + unique_together = ("session", "position") diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py index 0d045ea4d..c7c361de9 100644 --- a/api/funkwhale_api/radios/radios.py +++ b/api/funkwhale_api/radios/radios.py @@ -1,18 +1,18 @@ import random -from rest_framework import serializers -from django.db.models import Count -from django.core.exceptions import ValidationError -from taggit.models import Tag -from funkwhale_api.users.models import User -from funkwhale_api.music.models import Track, Artist -from . import filters -from . import models +from django.core.exceptions import ValidationError +from django.db.models import Count +from rest_framework import serializers +from taggit.models import Tag + +from funkwhale_api.music.models import Artist, Track +from funkwhale_api.users.models import User + +from . import filters, models from .registries import registry class SimpleRadio(object): - def clean(self, instance): return @@ -37,13 +37,13 @@ class SessionRadio(SimpleRadio): self.session = session def start_session(self, user, **kwargs): - self.session = models.RadioSession.objects.create(user=user, radio_type=self.radio_type, **kwargs) + self.session = models.RadioSession.objects.create( + user=user, radio_type=self.radio_type, **kwargs + ) return self.session def get_queryset(self, **kwargs): - qs = Track.objects.annotate( - files_count=Count('files') - ) + qs = Track.objects.annotate(files_count=Count("files")) return qs.filter(files_count__gt=0) def get_queryset_kwargs(self): @@ -57,7 +57,9 @@ class SessionRadio(SimpleRadio): return queryset def filter_from_session(self, queryset): - already_played = self.session.session_tracks.all().values_list('track', flat=True) + already_played = self.session.session_tracks.all().values_list( + "track", flat=True + ) queryset = queryset.exclude(pk__in=already_played) return queryset @@ -76,60 +78,51 @@ class SessionRadio(SimpleRadio): return data -@registry.register(name='random') +@registry.register(name="random") class RandomRadio(SessionRadio): def get_queryset(self, **kwargs): qs = super().get_queryset(**kwargs) - return qs.order_by('?') + return qs.order_by("?") -@registry.register(name='favorites') +@registry.register(name="favorites") class FavoritesRadio(SessionRadio): - def get_queryset_kwargs(self): kwargs = super().get_queryset_kwargs() if self.session: - kwargs['user'] = self.session.user + kwargs["user"] = self.session.user return kwargs def get_queryset(self, **kwargs): qs = super().get_queryset(**kwargs) - track_ids = kwargs['user'].track_favorites.all().values_list('track', flat=True) + track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True) return qs.filter(pk__in=track_ids) -@registry.register(name='custom') +@registry.register(name="custom") class CustomRadio(SessionRadio): - def get_queryset_kwargs(self): kwargs = super().get_queryset_kwargs() - kwargs['user'] = self.session.user - kwargs['custom_radio'] = self.session.custom_radio + kwargs["user"] = self.session.user + kwargs["custom_radio"] = self.session.custom_radio return kwargs def get_queryset(self, **kwargs): qs = super().get_queryset(**kwargs) - return filters.run( - kwargs['custom_radio'].config, - candidates=qs, - ) + return filters.run(kwargs["custom_radio"].config, candidates=qs) def validate_session(self, data, **context): data = super().validate_session(data, **context) try: - user = data['user'] + user = data["user"] except KeyError: - user = context['user'] + user = context["user"] try: - assert ( - data['custom_radio'].user == user or - data['custom_radio'].is_public) + assert data["custom_radio"].user == user or data["custom_radio"].is_public except KeyError: - raise serializers.ValidationError( - 'You must provide a custom radio') + raise serializers.ValidationError("You must provide a custom radio") except AssertionError: - raise serializers.ValidationError( - "You don't have access to this radio") + raise serializers.ValidationError("You don't have access to this radio") return data @@ -139,23 +132,26 @@ class RelatedObjectRadio(SessionRadio): def clean(self, instance): super().clean(instance) if not instance.related_object: - raise ValidationError('Cannot start RelatedObjectRadio without related object') + raise ValidationError( + "Cannot start RelatedObjectRadio without related object" + ) if not isinstance(instance.related_object, self.model): - raise ValidationError('Trying to start radio with bad related object') + raise ValidationError("Trying to start radio with bad related object") def get_related_object(self, pk): return self.model.objects.get(pk=pk) -@registry.register(name='tag') +@registry.register(name="tag") class TagRadio(RelatedObjectRadio): model = Tag def get_queryset(self, **kwargs): qs = super().get_queryset(**kwargs) - return Track.objects.filter(tags__in=[self.session.related_object]) + return qs.filter(tags__in=[self.session.related_object]) -@registry.register(name='artist') + +@registry.register(name="artist") class ArtistRadio(RelatedObjectRadio): model = Artist @@ -164,7 +160,7 @@ class ArtistRadio(RelatedObjectRadio): return qs.filter(artist=self.session.related_object) -@registry.register(name='less-listened') +@registry.register(name="less-listened") class LessListenedRadio(RelatedObjectRadio): model = User @@ -174,5 +170,5 @@ class LessListenedRadio(RelatedObjectRadio): def get_queryset(self, **kwargs): qs = super().get_queryset(**kwargs) - listened = self.session.user.listenings.all().values_list('track', flat=True) - return qs.exclude(pk__in=listened).order_by('?') + listened = self.session.user.listenings.all().values_list("track", flat=True) + return qs.exclude(pk__in=listened).order_by("?") diff --git a/api/funkwhale_api/radios/registries.py b/api/funkwhale_api/radios/registries.py index eec223539..4a30102b7 100644 --- a/api/funkwhale_api/radios/registries.py +++ b/api/funkwhale_api/radios/registries.py @@ -1,8 +1,10 @@ import persisting_theory + class RadioRegistry(persisting_theory.Registry): def prepare_name(self, data, name=None): - setattr(data, 'radio_type', name) + setattr(data, "radio_type", name) return name -registry = RadioRegistry() + +registry = RadioRegistry() diff --git a/api/funkwhale_api/radios/serializers.py b/api/funkwhale_api/radios/serializers.py index 8c59f8715..9bffbf5b9 100644 --- a/api/funkwhale_api/radios/serializers.py +++ b/api/funkwhale_api/radios/serializers.py @@ -3,13 +3,12 @@ from rest_framework import serializers from funkwhale_api.music.serializers import TrackSerializer from funkwhale_api.users.serializers import UserBasicSerializer -from . import filters -from . import models +from . import filters, models from .radios import registry class FilterSerializer(serializers.Serializer): - type = serializers.CharField(source='code') + type = serializers.CharField(source="code") label = serializers.CharField() help_text = serializers.CharField() fields = serializers.ReadOnlyField() @@ -21,19 +20,20 @@ class RadioSerializer(serializers.ModelSerializer): class Meta: model = models.Radio fields = ( - 'id', - 'is_public', - 'name', - 'creation_date', - 'user', - 'config', - 'description') - read_only_fields = ('user', 'creation_date') + "id", + "is_public", + "name", + "creation_date", + "user", + "config", + "description", + ) + read_only_fields = ("user", "creation_date") def save(self, **kwargs): - kwargs['config'] = [ - filters.registry[f['type']].clean_config(f) - for f in self.validated_data['config'] + kwargs["config"] = [ + filters.registry[f["type"]].clean_config(f) + for f in self.validated_data["config"] ] return super().save(**kwargs) @@ -42,7 +42,7 @@ class RadioSerializer(serializers.ModelSerializer): class RadioSessionTrackSerializerCreate(serializers.ModelSerializer): class Meta: model = models.RadioSessionTrack - fields = ('session',) + fields = ("session",) class RadioSessionTrackSerializer(serializers.ModelSerializer): @@ -50,28 +50,30 @@ class RadioSessionTrackSerializer(serializers.ModelSerializer): class Meta: model = models.RadioSessionTrack - fields = ('id', 'session', 'position', 'track') + fields = ("id", "session", "position", "track") class RadioSessionSerializer(serializers.ModelSerializer): class Meta: model = models.RadioSession fields = ( - 'id', - 'radio_type', - 'related_object_id', - 'user', - 'creation_date', - 'custom_radio', + "id", + "radio_type", + "related_object_id", + "user", + "creation_date", + "custom_radio", ) def validate(self, data): - registry[data['radio_type']]().validate_session(data, **self.context) + registry[data["radio_type"]]().validate_session(data, **self.context) return data def create(self, validated_data): - validated_data['user'] = self.context['user'] - if validated_data.get('related_object_id'): - radio = registry[validated_data['radio_type']]() - validated_data['related_object'] = radio.get_related_object(validated_data['related_object_id']) + validated_data["user"] = self.context["user"] + if validated_data.get("related_object_id"): + radio = registry[validated_data["radio_type"]]() + validated_data["related_object"] = radio.get_related_object( + validated_data["related_object_id"] + ) return super().create(validated_data) diff --git a/api/funkwhale_api/radios/urls.py b/api/funkwhale_api/radios/urls.py index d84615ca5..8b9fd52c8 100644 --- a/api/funkwhale_api/radios/urls.py +++ b/api/funkwhale_api/radios/urls.py @@ -1,11 +1,11 @@ -from django.conf.urls import include, url +from rest_framework import routers + from . import views -from rest_framework import routers router = routers.SimpleRouter() -router.register(r'sessions', views.RadioSessionViewSet, 'sessions') -router.register(r'radios', views.RadioViewSet, 'radios') -router.register(r'tracks', views.RadioSessionTrackViewSet, 'tracks') +router.register(r"sessions", views.RadioSessionViewSet, "sessions") +router.register(r"radios", views.RadioViewSet, "radios") +router.register(r"tracks", views.RadioSessionTrackViewSet, "tracks") urlpatterns = router.urls diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py index ca510b82c..fb2c4d855 100644 --- a/api/funkwhale_api/radios/views.py +++ b/api/funkwhale_api/radios/views.py @@ -1,48 +1,46 @@ from django.db.models import Q -from django.http import Http404 - -from rest_framework import generics, mixins, viewsets -from rest_framework import permissions -from rest_framework import status -from rest_framework.response import Response +from rest_framework import mixins, permissions, status, viewsets from rest_framework.decorators import detail_route, list_route +from rest_framework.response import Response +from funkwhale_api.common import permissions as common_permissions from funkwhale_api.music.serializers import TrackSerializer -from funkwhale_api.common.permissions import ConditionalAuthentication -from . import models -from . import filters -from . import filtersets -from . import serializers +from . import filters, filtersets, models, serializers class RadioViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.ListModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet): + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): serializer_class = serializers.RadioSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [ + permissions.IsAuthenticated, + common_permissions.OwnerPermission, + ] filter_class = filtersets.RadioFilter + owner_field = "user" + owner_checks = ["write"] def get_queryset(self): + queryset = models.Radio.objects.all() query = Q(is_public=True) if self.request.user.is_authenticated: query |= Q(user=self.request.user) - return models.Radio.objects.filter(query) + return queryset.filter(query) def perform_create(self, serializer): return serializer.save(user=self.request.user) def perform_update(self, serializer): - if serializer.instance.user != self.request.user: - raise Http404 return serializer.save(user=self.request.user) - @detail_route(methods=['get']) + @detail_route(methods=["get"]) def tracks(self, request, *args, **kwargs): radio = self.get_object() tracks = radio.get_candidates().for_nested_serialization() @@ -52,36 +50,33 @@ class RadioViewSet( serializer = TrackSerializer(page, many=True) return self.get_paginated_response(serializer.data) - @list_route(methods=['get']) + @list_route(methods=["get"]) def filters(self, request, *args, **kwargs): serializer = serializers.FilterSerializer( - filters.registry.exposed_filters, many=True) + filters.registry.exposed_filters, many=True + ) return Response(serializer.data) - @list_route(methods=['post']) + @list_route(methods=["post"]) def validate(self, request, *args, **kwargs): try: - f_list = request.data['filters'] + f_list = request.data["filters"] except KeyError: - return Response( - {'error': 'You must provide a filters list'}, status=400) - data = { - 'filters': [] - } + return Response({"error": "You must provide a filters list"}, status=400) + data = {"filters": []} for f in f_list: results = filters.test(f) - if results['candidates']['sample']: - qs = results['candidates']['sample'].for_nested_serialization() - results['candidates']['sample'] = TrackSerializer( - qs, many=True).data - data['filters'].append(results) + if results["candidates"]["sample"]: + qs = results["candidates"]["sample"].for_nested_serialization() + results["candidates"]["sample"] = TrackSerializer(qs, many=True).data + data["filters"].append(results) return Response(data) -class RadioSessionViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - viewsets.GenericViewSet): +class RadioSessionViewSet( + mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): serializer_class = serializers.RadioSessionSerializer queryset = models.RadioSession.objects.all() @@ -93,12 +88,11 @@ class RadioSessionViewSet(mixins.CreateModelMixin, def get_serializer_context(self): context = super().get_serializer_context() - context['user'] = self.request.user + context["user"] = self.request.user return context -class RadioSessionTrackViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): +class RadioSessionTrackViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): serializer_class = serializers.RadioSessionTrackSerializer queryset = models.RadioSessionTrack.objects.all() permission_classes = [permissions.IsAuthenticated] @@ -106,20 +100,24 @@ class RadioSessionTrackViewSet(mixins.CreateModelMixin, def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - session = serializer.validated_data['session'] + session = serializer.validated_data["session"] try: assert request.user == session.user except AssertionError: return Response(status=status.HTTP_403_FORBIDDEN) - track = session.radio.pick() - session_track = session.session_tracks.all().latest('id') + session.radio.pick() + session_track = session.session_tracks.all().latest("id") # self.perform_create(serializer) # dirty override here, since we use a different serializer for creation and detail - serializer = self.serializer_class(instance=session_track, context=self.get_serializer_context()) + serializer = self.serializer_class( + instance=session_track, context=self.get_serializer_context() + ) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) def get_serializer_class(self, *args, **kwargs): - if self.action == 'create': + if self.action == "create": return serializers.RadioSessionTrackSerializerCreate return super().get_serializer_class(*args, **kwargs) diff --git a/api/funkwhale_api/requests/admin.py b/api/funkwhale_api/requests/admin.py index 8ca008a03..b0f1a7990 100644 --- a/api/funkwhale_api/requests/admin.py +++ b/api/funkwhale_api/requests/admin.py @@ -5,11 +5,7 @@ from . import models @admin.register(models.ImportRequest) class ImportRequestAdmin(admin.ModelAdmin): - list_display = ['artist_name', 'user', 'status', 'creation_date'] - list_select_related = [ - 'user' - ] - list_filter = [ - 'status', - ] - search_fields = ['artist_name', 'comment', 'albums'] + list_display = ["artist_name", "user", "status", "creation_date"] + list_select_related = ["user"] + list_filter = ["status"] + search_fields = ["artist_name", "comment", "albums"] diff --git a/api/funkwhale_api/requests/api_urls.py b/api/funkwhale_api/requests/api_urls.py index 37459a664..403a0953b 100644 --- a/api/funkwhale_api/requests/api_urls.py +++ b/api/funkwhale_api/requests/api_urls.py @@ -1,11 +1,8 @@ -from django.conf.urls import include, url +from rest_framework import routers + from . import views -from rest_framework import routers router = routers.SimpleRouter() -router.register( - r'import-requests', - views.ImportRequestViewSet, - 'import-requests') +router.register(r"import-requests", views.ImportRequestViewSet, "import-requests") urlpatterns = router.urls diff --git a/api/funkwhale_api/requests/factories.py b/api/funkwhale_api/requests/factories.py index 2bcdeb6a9..d6673aebd 100644 --- a/api/funkwhale_api/requests/factories.py +++ b/api/funkwhale_api/requests/factories.py @@ -6,10 +6,10 @@ from funkwhale_api.users.factories import UserFactory @registry.register class ImportRequestFactory(factory.django.DjangoModelFactory): - artist_name = factory.Faker('name') - albums = factory.Faker('sentence') + artist_name = factory.Faker("name") + albums = factory.Faker("sentence") user = factory.SubFactory(UserFactory) - comment = factory.Faker('paragraph') + comment = factory.Faker("paragraph") class Meta: - model = 'requests.ImportRequest' + model = "requests.ImportRequest" diff --git a/api/funkwhale_api/requests/filters.py b/api/funkwhale_api/requests/filters.py index 7d0603362..4a06dea1b 100644 --- a/api/funkwhale_api/requests/filters.py +++ b/api/funkwhale_api/requests/filters.py @@ -1,22 +1,20 @@ import django_filters from funkwhale_api.common import fields + from . import models class ImportRequestFilter(django_filters.FilterSet): - q = fields.SearchFilter(search_fields=[ - 'artist_name', - 'user__username', - 'albums', - 'comment', - ]) + q = fields.SearchFilter( + search_fields=["artist_name", "user__username", "albums", "comment"] + ) class Meta: model = models.ImportRequest fields = { - 'artist_name': ['exact', 'iexact', 'startswith', 'icontains'], - 'status': ['exact'], - 'user__username': ['exact'], + "artist_name": ["exact", "iexact", "startswith", "icontains"], + "status": ["exact"], + "user__username": ["exact"], } diff --git a/api/funkwhale_api/requests/migrations/0001_initial.py b/api/funkwhale_api/requests/migrations/0001_initial.py index 7c239b3c0..ab9b619ef 100644 --- a/api/funkwhale_api/requests/migrations/0001_initial.py +++ b/api/funkwhale_api/requests/migrations/0001_initial.py @@ -10,22 +10,50 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( - name='ImportRequest', + name="ImportRequest", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), - ('imported_date', models.DateTimeField(blank=True, null=True)), - ('artist_name', models.CharField(max_length=250)), - ('albums', models.CharField(blank=True, max_length=3000, null=True)), - ('status', models.CharField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('imported', 'imported'), ('closed', 'closed')], default='pending', max_length=50)), - ('comment', models.TextField(blank=True, max_length=3000, null=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='import_requests', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("imported_date", models.DateTimeField(blank=True, null=True)), + ("artist_name", models.CharField(max_length=250)), + ("albums", models.CharField(blank=True, max_length=3000, null=True)), + ( + "status", + models.CharField( + choices=[ + ("pending", "pending"), + ("accepted", "accepted"), + ("imported", "imported"), + ("closed", "closed"), + ], + default="pending", + max_length=50, + ), + ), + ("comment", models.TextField(blank=True, max_length=3000, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="import_requests", + to=settings.AUTH_USER_MODEL, + ), + ), ], - ), + ) ] diff --git a/api/funkwhale_api/requests/models.py b/api/funkwhale_api/requests/models.py index d08dd4004..a24086d7a 100644 --- a/api/funkwhale_api/requests/models.py +++ b/api/funkwhale_api/requests/models.py @@ -1,18 +1,13 @@ from django.db import models - from django.utils import timezone -NATURE_CHOICES = [ - ('artist', 'artist'), - ('album', 'album'), - ('track', 'track'), -] +NATURE_CHOICES = [("artist", "artist"), ("album", "album"), ("track", "track")] STATUS_CHOICES = [ - ('pending', 'pending'), - ('accepted', 'accepted'), - ('imported', 'imported'), - ('closed', 'closed'), + ("pending", "pending"), + ("accepted", "accepted"), + ("imported", "imported"), + ("closed", "closed"), ] @@ -20,11 +15,9 @@ class ImportRequest(models.Model): creation_date = models.DateTimeField(default=timezone.now) imported_date = models.DateTimeField(null=True, blank=True) user = models.ForeignKey( - 'users.User', - related_name='import_requests', - on_delete=models.CASCADE) + "users.User", related_name="import_requests", on_delete=models.CASCADE + ) artist_name = models.CharField(max_length=250) albums = models.CharField(max_length=3000, null=True, blank=True) - status = models.CharField( - choices=STATUS_CHOICES, max_length=50, default='pending') + status = models.CharField(choices=STATUS_CHOICES, max_length=50, default="pending") comment = models.TextField(null=True, blank=True, max_length=3000) diff --git a/api/funkwhale_api/requests/serializers.py b/api/funkwhale_api/requests/serializers.py index 51a709514..2a810a999 100644 --- a/api/funkwhale_api/requests/serializers.py +++ b/api/funkwhale_api/requests/serializers.py @@ -11,20 +11,17 @@ class ImportRequestSerializer(serializers.ModelSerializer): class Meta: model = models.ImportRequest fields = ( - 'id', - 'status', - 'albums', - 'artist_name', - 'user', - 'creation_date', - 'imported_date', - 'comment') - read_only_fields = ( - 'creation_date', - 'imported_date', - 'user', - 'status') + "id", + "status", + "albums", + "artist_name", + "user", + "creation_date", + "imported_date", + "comment", + ) + read_only_fields = ("creation_date", "imported_date", "user", "status") def create(self, validated_data): - validated_data['user'] = self.context['user'] + validated_data["user"] = self.context["user"] return super().create(validated_data) diff --git a/api/funkwhale_api/requests/views.py b/api/funkwhale_api/requests/views.py index 6553f3316..96d8c8927 100644 --- a/api/funkwhale_api/requests/views.py +++ b/api/funkwhale_api/requests/views.py @@ -1,26 +1,21 @@ -from rest_framework import generics, mixins, viewsets -from rest_framework import status -from rest_framework.response import Response -from rest_framework.decorators import detail_route +from rest_framework import mixins, viewsets -from . import filters -from . import models -from . import serializers +from . import filters, models, serializers class ImportRequestViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): serializer_class = serializers.ImportRequestSerializer queryset = ( - models.ImportRequest.objects.all() - .select_related() - .order_by('-creation_date')) + models.ImportRequest.objects.all().select_related().order_by("-creation_date") + ) filter_class = filters.ImportRequestFilter - ordering_fields = ('id', 'artist_name', 'creation_date', 'status') + ordering_fields = ("id", "artist_name", "creation_date", "status") def perform_create(self, serializer): return serializer.save(user=self.request.user) @@ -28,5 +23,5 @@ class ImportRequestViewSet( def get_serializer_context(self): context = super().get_serializer_context() if self.request.user.is_authenticated: - context['user'] = self.request.user + context["user"] = self.request.user return context diff --git a/api/funkwhale_api/subsonic/authentication.py b/api/funkwhale_api/subsonic/authentication.py index fe9b08dc8..a573a1092 100644 --- a/api/funkwhale_api/subsonic/authentication.py +++ b/api/funkwhale_api/subsonic/authentication.py @@ -1,8 +1,7 @@ import binascii import hashlib -from rest_framework import authentication -from rest_framework import exceptions +from rest_framework import authentication, exceptions from funkwhale_api.users.models import User @@ -10,23 +9,20 @@ from funkwhale_api.users.models import User def get_token(salt, password): to_hash = password + salt h = hashlib.md5() - h.update(to_hash.encode('utf-8')) + h.update(to_hash.encode("utf-8")) return h.hexdigest() def authenticate(username, password): try: - if password.startswith('enc:'): - password = password.replace('enc:', '', 1) - password = binascii.unhexlify(password).decode('utf-8') + if password.startswith("enc:"): + password = password.replace("enc:", "", 1) + password = binascii.unhexlify(password).decode("utf-8") user = User.objects.get( - username=username, - is_active=True, - subsonic_api_token=password) - except (User.DoesNotExist, binascii.Error): - raise exceptions.AuthenticationFailed( - 'Wrong username or password.' + username=username, is_active=True, subsonic_api_token=password ) + except (User.DoesNotExist, binascii.Error): + raise exceptions.AuthenticationFailed("Wrong username or password.") return (user, None) @@ -34,18 +30,13 @@ def authenticate(username, password): def authenticate_salt(username, salt, token): try: user = User.objects.get( - username=username, - is_active=True, - subsonic_api_token__isnull=False) - except User.DoesNotExist: - raise exceptions.AuthenticationFailed( - 'Wrong username or password.' + username=username, is_active=True, subsonic_api_token__isnull=False ) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed("Wrong username or password.") expected = get_token(salt, user.subsonic_api_token) if expected != token: - raise exceptions.AuthenticationFailed( - 'Wrong username or password.' - ) + raise exceptions.AuthenticationFailed("Wrong username or password.") return (user, None) @@ -53,15 +44,15 @@ def authenticate_salt(username, salt, token): class SubsonicAuthentication(authentication.BaseAuthentication): def authenticate(self, request): data = request.GET or request.POST - username = data.get('u') + username = data.get("u") if not username: return None - p = data.get('p') - s = data.get('s') - t = data.get('t') + p = data.get("p") + s = data.get("s") + t = data.get("t") if not p and (not s or not t): - raise exceptions.AuthenticationFailed('Missing credentials') + raise exceptions.AuthenticationFailed("Missing credentials") if p: return authenticate(username, p) diff --git a/api/funkwhale_api/subsonic/dynamic_preferences_registry.py b/api/funkwhale_api/subsonic/dynamic_preferences_registry.py index 93482702f..439d16de3 100644 --- a/api/funkwhale_api/subsonic/dynamic_preferences_registry.py +++ b/api/funkwhale_api/subsonic/dynamic_preferences_registry.py @@ -1,22 +1,20 @@ from dynamic_preferences import types from dynamic_preferences.registries import global_preferences_registry -from funkwhale_api.common import preferences - -subsonic = types.Section('subsonic') +subsonic = types.Section("subsonic") @global_preferences_registry.register class APIAutenticationRequired(types.BooleanPreference): section = subsonic show_in_api = True - name = 'enabled' + name = "enabled" default = True - verbose_name = 'Enabled Subsonic API' + verbose_name = "Enabled Subsonic API" help_text = ( - 'Funkwhale supports a subset of the Subsonic API, that makes ' - 'it compatible with existing clients such as DSub for Android ' - 'or Clementine for desktop. However, Subsonic protocol is less ' - 'than ideal in terms of security and you can disable this feature ' - 'completely using this flag.' + "Funkwhale supports a subset of the Subsonic API, that makes " + "it compatible with existing clients such as DSub for Android " + "or Clementine for desktop. However, Subsonic protocol is less " + "than ideal in terms of security and you can disable this feature " + "completely using this flag." ) diff --git a/api/funkwhale_api/subsonic/filters.py b/api/funkwhale_api/subsonic/filters.py index b7b639fac..a354e23f1 100644 --- a/api/funkwhale_api/subsonic/filters.py +++ b/api/funkwhale_api/subsonic/filters.py @@ -4,18 +4,18 @@ from funkwhale_api.music import models as music_models class AlbumList2FilterSet(filters.FilterSet): - type = filters.CharFilter(name='_', method='filter_type') + type = filters.CharFilter(name="_", method="filter_type") class Meta: model = music_models.Album - fields = ['type'] + fields = ["type"] def filter_type(self, queryset, name, value): ORDERING = { - 'random': '?', - 'newest': '-creation_date', - 'alphabeticalByArtist': 'artist__name', - 'alphabeticalByName': 'title', + "random": "?", + "newest": "-creation_date", + "alphabeticalByArtist": "artist__name", + "alphabeticalByName": "title", } if value not in ORDERING: return queryset diff --git a/api/funkwhale_api/subsonic/negotiation.py b/api/funkwhale_api/subsonic/negotiation.py index 3335fda45..96b41589e 100644 --- a/api/funkwhale_api/subsonic/negotiation.py +++ b/api/funkwhale_api/subsonic/negotiation.py @@ -1,20 +1,17 @@ -from rest_framework import exceptions -from rest_framework import negotiation +from rest_framework import exceptions, negotiation from . import renderers - MAPPING = { - 'json': (renderers.SubsonicJSONRenderer(), 'application/json'), - 'xml': (renderers.SubsonicXMLRenderer(), 'text/xml'), + "json": (renderers.SubsonicJSONRenderer(), "application/json"), + "xml": (renderers.SubsonicXMLRenderer(), "text/xml"), } class SubsonicContentNegociation(negotiation.DefaultContentNegotiation): def select_renderer(self, request, renderers, format_suffix=None): - path = request.path data = request.GET or request.POST - requested_format = data.get('f', 'xml') + requested_format = data.get("f", "xml") try: return MAPPING[requested_format] except KeyError: diff --git a/api/funkwhale_api/subsonic/renderers.py b/api/funkwhale_api/subsonic/renderers.py index 3a5664501..fac12d6c1 100644 --- a/api/funkwhale_api/subsonic/renderers.py +++ b/api/funkwhale_api/subsonic/renderers.py @@ -8,37 +8,34 @@ class SubsonicJSONRenderer(renderers.JSONRenderer): if not data: # when stream view is called, we don't have any data return super().render(data, accepted_media_type, renderer_context) - final = { - 'subsonic-response': { - 'status': 'ok', - 'version': '1.16.0', - } - } - final['subsonic-response'].update(data) - if 'error' in final: + final = {"subsonic-response": {"status": "ok", "version": "1.16.0"}} + final["subsonic-response"].update(data) + if "error" in final: # an error was returned - final['subsonic-response']['status'] = 'failed' + final["subsonic-response"]["status"] = "failed" return super().render(final, accepted_media_type, renderer_context) class SubsonicXMLRenderer(renderers.JSONRenderer): - media_type = 'text/xml' + media_type = "text/xml" def render(self, data, accepted_media_type=None, renderer_context=None): if not data: # when stream view is called, we don't have any data return super().render(data, accepted_media_type, renderer_context) final = { - 'xmlns': 'http://subsonic.org/restapi', - 'status': 'ok', - 'version': '1.16.0', + "xmlns": "http://subsonic.org/restapi", + "status": "ok", + "version": "1.16.0", } final.update(data) - if 'error' in final: + if "error" in final: # an error was returned - final['status'] = 'failed' - tree = dict_to_xml_tree('subsonic-response', final) - return b'\n' + ET.tostring(tree, encoding='utf-8') + final["status"] = "failed" + tree = dict_to_xml_tree("subsonic-response", final) + return b'\n' + ET.tostring( + tree, encoding="utf-8" + ) def dict_to_xml_tree(root_tag, d, parent=None): diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 97cdbcfc6..fc21a99f2 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -1,7 +1,6 @@ import collections -from django.db.models import functions, Count - +from django.db.models import Count, functions from rest_framework import serializers from funkwhale_api.history import models as history_models @@ -10,106 +9,100 @@ from funkwhale_api.music import models as music_models def get_artist_data(artist_values): return { - 'id': artist_values['id'], - 'name': artist_values['name'], - 'albumCount': artist_values['_albums_count'] + "id": artist_values["id"], + "name": artist_values["name"], + "albumCount": artist_values["_albums_count"], } class GetArtistsSerializer(serializers.Serializer): def to_representation(self, queryset): - payload = { - 'ignoredArticles': '', - 'index': [] - } + payload = {"ignoredArticles": "", "index": []} queryset = queryset.with_albums_count() - queryset = queryset.order_by(functions.Lower('name')) - values = queryset.values('id', '_albums_count', 'name') + queryset = queryset.order_by(functions.Lower("name")) + values = queryset.values("id", "_albums_count", "name") first_letter_mapping = collections.defaultdict(list) for artist in values: - first_letter_mapping[artist['name'][0].upper()].append(artist) + first_letter_mapping[artist["name"][0].upper()].append(artist) for letter, artists in sorted(first_letter_mapping.items()): letter_data = { - 'name': letter, - 'artist': [ - get_artist_data(v) - for v in artists - ] + "name": letter, + "artist": [get_artist_data(v) for v in artists], } - payload['index'].append(letter_data) + payload["index"].append(letter_data) return payload class GetArtistSerializer(serializers.Serializer): def to_representation(self, artist): - albums = artist.albums.prefetch_related('tracks__files') + albums = artist.albums.prefetch_related("tracks__files") payload = { - 'id': artist.pk, - 'name': artist.name, - 'albumCount': len(albums), - 'album': [], + "id": artist.pk, + "name": artist.name, + "albumCount": len(albums), + "album": [], } for album in albums: album_data = { - 'id': album.id, - 'artistId': artist.id, - 'name': album.title, - 'artist': artist.name, - 'created': album.creation_date, - 'songCount': len(album.tracks.all()), + "id": album.id, + "artistId": artist.id, + "name": album.title, + "artist": artist.name, + "created": album.creation_date, + "songCount": len(album.tracks.all()), } if album.cover: - album_data['coverArt'] = 'al-{}'.format(album.id) + album_data["coverArt"] = "al-{}".format(album.id) if album.release_date: - album_data['year'] = album.release_date.year - payload['album'].append(album_data) + album_data["year"] = album.release_date.year + payload["album"].append(album_data) return payload def get_track_data(album, track, tf): data = { - 'id': track.pk, - 'isDir': 'false', - 'title': track.title, - 'album': album.title, - 'artist': album.artist.name, - 'track': track.position or 1, - 'contentType': tf.mimetype, - 'suffix': tf.extension or '', - 'duration': tf.duration or 0, - 'created': track.creation_date, - 'albumId': album.pk, - 'artistId': album.artist.pk, - 'type': 'music', + "id": track.pk, + "isDir": "false", + "title": track.title, + "album": album.title, + "artist": album.artist.name, + "track": track.position or 1, + "contentType": tf.mimetype, + "suffix": tf.extension or "", + "duration": tf.duration or 0, + "created": track.creation_date, + "albumId": album.pk, + "artistId": album.artist.pk, + "type": "music", } if track.album.cover: - data['coverArt'] = 'al-{}'.format(track.album.id) + data["coverArt"] = "al-{}".format(track.album.id) if tf.bitrate: - data['bitrate'] = int(tf.bitrate/1000) + data["bitrate"] = int(tf.bitrate / 1000) if tf.size: - data['size'] = tf.size + data["size"] = tf.size if album.release_date: - data['year'] = album.release_date.year + data["year"] = album.release_date.year return data def get_album2_data(album): payload = { - 'id': album.id, - 'artistId': album.artist.id, - 'name': album.title, - 'artist': album.artist.name, - 'created': album.creation_date, + "id": album.id, + "artistId": album.artist.id, + "name": album.title, + "artist": album.artist.name, + "created": album.creation_date, } if album.cover: - payload['coverArt'] = 'al-{}'.format(album.id) + payload["coverArt"] = "al-{}".format(album.id) try: - payload['songCount'] = album._tracks_count + payload["songCount"] = album._tracks_count except AttributeError: - payload['songCount'] = len(album.tracks.prefetch_related('files')) + payload["songCount"] = len(album.tracks.prefetch_related("files")) return payload @@ -127,24 +120,23 @@ def get_song_list_data(tracks): class GetAlbumSerializer(serializers.Serializer): def to_representation(self, album): - tracks = album.tracks.prefetch_related('files').select_related('album') + tracks = album.tracks.prefetch_related("files").select_related("album") payload = get_album2_data(album) if album.release_date: - payload['year'] = album.release_date.year + payload["year"] = album.release_date.year - payload['song'] = get_song_list_data(tracks) + payload["song"] = get_song_list_data(tracks) return payload def get_starred_tracks_data(favorites): - by_track_id = { - f.track_id: f - for f in favorites - } - tracks = music_models.Track.objects.filter( - pk__in=by_track_id.keys() - ).select_related('album__artist').prefetch_related('files') - tracks = tracks.order_by('-creation_date') + by_track_id = {f.track_id: f for f in favorites} + tracks = ( + music_models.Track.objects.filter(pk__in=by_track_id.keys()) + .select_related("album__artist") + .prefetch_related("files") + ) + tracks = tracks.order_by("-creation_date") data = [] for t in tracks: try: @@ -152,54 +144,48 @@ def get_starred_tracks_data(favorites): except IndexError: continue td = get_track_data(t.album, t, tf) - td['starred'] = by_track_id[t.pk].creation_date + td["starred"] = by_track_id[t.pk].creation_date data.append(td) return data def get_album_list2_data(albums): - return [ - get_album2_data(a) - for a in albums - ] + return [get_album2_data(a) for a in albums] def get_playlist_data(playlist): return { - 'id': playlist.pk, - 'name': playlist.name, - 'owner': playlist.user.username, - 'public': 'false', - 'songCount': playlist._tracks_count, - 'duration': 0, - 'created': playlist.creation_date, + "id": playlist.pk, + "name": playlist.name, + "owner": playlist.user.username, + "public": "false", + "songCount": playlist._tracks_count, + "duration": 0, + "created": playlist.creation_date, } def get_playlist_detail_data(playlist): data = get_playlist_data(playlist) - qs = playlist.playlist_tracks.select_related( - 'track__album__artist' - ).prefetch_related('track__files').order_by('index') - data['entry'] = [] + qs = ( + playlist.playlist_tracks.select_related("track__album__artist") + .prefetch_related("track__files") + .order_by("index") + ) + data["entry"] = [] for plt in qs: try: tf = [tf for tf in plt.track.files.all()][0] except IndexError: continue td = get_track_data(plt.track.album, plt.track, tf) - data['entry'].append(td) + data["entry"].append(td) return data def get_music_directory_data(artist): - tracks = artist.tracks.select_related('album').prefetch_related('files') - data = { - 'id': artist.pk, - 'parent': 1, - 'name': artist.name, - 'child': [] - } + tracks = artist.tracks.select_related("album").prefetch_related("files") + data = {"id": artist.pk, "parent": 1, "name": artist.name, "child": []} for track in tracks: try: tf = [tf for tf in track.files.all()][0] @@ -207,40 +193,39 @@ def get_music_directory_data(artist): continue album = track.album td = { - 'id': track.pk, - 'isDir': 'false', - 'title': track.title, - 'album': album.title, - 'artist': artist.name, - 'track': track.position or 1, - 'year': track.album.release_date.year if track.album.release_date else 0, - 'contentType': tf.mimetype, - 'suffix': tf.extension or '', - 'duration': tf.duration or 0, - 'created': track.creation_date, - 'albumId': album.pk, - 'artistId': artist.pk, - 'parent': artist.id, - 'type': 'music', + "id": track.pk, + "isDir": "false", + "title": track.title, + "album": album.title, + "artist": artist.name, + "track": track.position or 1, + "year": track.album.release_date.year if track.album.release_date else 0, + "contentType": tf.mimetype, + "suffix": tf.extension or "", + "duration": tf.duration or 0, + "created": track.creation_date, + "albumId": album.pk, + "artistId": artist.pk, + "parent": artist.id, + "type": "music", } if tf.bitrate: - td['bitrate'] = int(tf.bitrate/1000) + td["bitrate"] = int(tf.bitrate / 1000) if tf.size: - td['size'] = tf.size - data['child'].append(td) + td["size"] = tf.size + data["child"].append(td) return data class ScrobbleSerializer(serializers.Serializer): submission = serializers.BooleanField(default=True, required=False) id = serializers.PrimaryKeyRelatedField( - queryset=music_models.Track.objects.annotate( - files_count=Count('files') - ).filter(files_count__gt=0) + queryset=music_models.Track.objects.annotate(files_count=Count("files")).filter( + files_count__gt=0 + ) ) def create(self, data): return history_models.Listening.objects.create( - user=self.context['user'], - track=data['id'], + user=self.context["user"], track=data["id"] ) diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index cc75b5279..bb5f44166 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -2,12 +2,9 @@ import datetime from django.conf import settings from django.utils import timezone - from rest_framework import exceptions from rest_framework import permissions as rest_permissions -from rest_framework import renderers -from rest_framework import response -from rest_framework import viewsets +from rest_framework import renderers, response, viewsets from rest_framework.decorators import list_route from rest_framework.serializers import ValidationError @@ -19,50 +16,58 @@ from funkwhale_api.music import utils from funkwhale_api.music import views as music_views from funkwhale_api.playlists import models as playlists_models -from . import authentication -from . import filters -from . import negotiation -from . import serializers +from . import authentication, filters, negotiation, serializers -def find_object(queryset, model_field='pk', field='id', cast=int): +def find_object(queryset, model_field="pk", field="id", cast=int): def decorator(func): def inner(self, request, *args, **kwargs): data = request.GET or request.POST try: raw_value = data[field] except KeyError: - return response.Response({ - 'error': { - 'code': 10, - 'message': "required parameter '{}' not present".format(field) + return response.Response( + { + "error": { + "code": 10, + "message": "required parameter '{}' not present".format( + field + ), + } } - }) + ) try: value = cast(raw_value) except (TypeError, ValidationError): - return response.Response({ - 'error': { - 'code': 0, - 'message': 'For input string "{}"'.format(raw_value) + return response.Response( + { + "error": { + "code": 0, + "message": 'For input string "{}"'.format(raw_value), + } } - }) + ) qs = queryset - if hasattr(qs, '__call__'): + if hasattr(qs, "__call__"): qs = qs(request) try: obj = qs.get(**{model_field: value}) except qs.model.DoesNotExist: - return response.Response({ - 'error': { - 'code': 70, - 'message': '{} not found'.format( - qs.model.__class__.__name__) + return response.Response( + { + "error": { + "code": 70, + "message": "{} not found".format( + qs.model.__class__.__name__ + ), + } } - }) - kwargs['obj'] = obj + ) + kwargs["obj"] = obj return func(self, request, *args, **kwargs) + return inner + return decorator @@ -72,10 +77,10 @@ class SubsonicViewSet(viewsets.GenericViewSet): permissions_classes = [rest_permissions.IsAuthenticated] def dispatch(self, request, *args, **kwargs): - if not preferences.get('subsonic__enabled'): + if not preferences.get("subsonic__enabled"): r = response.Response({}, status=405) r.accepted_renderer = renderers.JSONRenderer() - r.accepted_media_type = 'application/json' + r.accepted_media_type = "application/json" r.renderer_context = {} return r return super().dispatch(request, *args, **kwargs) @@ -83,261 +88,187 @@ class SubsonicViewSet(viewsets.GenericViewSet): def handle_exception(self, exc): # subsonic API sends 200 status code with custom error # codes in the payload - mapping = { - exceptions.AuthenticationFailed: ( - 40, 'Wrong username or password.' - ) - } - payload = { - 'status': 'failed' - } + mapping = {exceptions.AuthenticationFailed: (40, "Wrong username or password.")} + payload = {"status": "failed"} if exc.__class__ in mapping: code, message = mapping[exc.__class__] else: return super().handle_exception(exc) - payload['error'] = { - 'code': code, - 'message': message - } + payload["error"] = {"code": code, "message": message} return response.Response(payload, status=200) - @list_route( - methods=['get', 'post'], - permission_classes=[]) + @list_route(methods=["get", "post"], permission_classes=[]) def ping(self, request, *args, **kwargs): - data = { - 'status': 'ok', - 'version': '1.16.0' - } + data = {"status": "ok", "version": "1.16.0"} return response.Response(data, status=200) @list_route( - methods=['get', 'post'], - url_name='get_license', + methods=["get", "post"], + url_name="get_license", permissions_classes=[], - url_path='getLicense') + url_path="getLicense", + ) def get_license(self, request, *args, **kwargs): now = timezone.now() data = { - 'status': 'ok', - 'version': '1.16.0', - 'license': { - 'valid': 'true', - 'email': 'valid@valid.license', - 'licenseExpires': now + datetime.timedelta(days=365) - } + "status": "ok", + "version": "1.16.0", + "license": { + "valid": "true", + "email": "valid@valid.license", + "licenseExpires": now + datetime.timedelta(days=365), + }, } return response.Response(data, status=200) - @list_route( - methods=['get', 'post'], - url_name='get_artists', - url_path='getArtists') + @list_route(methods=["get", "post"], url_name="get_artists", url_path="getArtists") def get_artists(self, request, *args, **kwargs): artists = music_models.Artist.objects.all() data = serializers.GetArtistsSerializer(artists).data - payload = { - 'artists': data - } + payload = {"artists": data} return response.Response(payload, status=200) - @list_route( - methods=['get', 'post'], - url_name='get_indexes', - url_path='getIndexes') + @list_route(methods=["get", "post"], url_name="get_indexes", url_path="getIndexes") def get_indexes(self, request, *args, **kwargs): artists = music_models.Artist.objects.all() data = serializers.GetArtistsSerializer(artists).data - payload = { - 'indexes': data - } + payload = {"indexes": data} return response.Response(payload, status=200) - @list_route( - methods=['get', 'post'], - url_name='get_artist', - url_path='getArtist') + @list_route(methods=["get", "post"], url_name="get_artist", url_path="getArtist") @find_object(music_models.Artist.objects.all()) def get_artist(self, request, *args, **kwargs): - artist = kwargs.pop('obj') + artist = kwargs.pop("obj") data = serializers.GetArtistSerializer(artist).data - payload = { - 'artist': data - } + payload = {"artist": data} return response.Response(payload, status=200) @list_route( - methods=['get', 'post'], - url_name='get_artist_info2', - url_path='getArtistInfo2') + methods=["get", "post"], url_name="get_artist_info2", url_path="getArtistInfo2" + ) @find_object(music_models.Artist.objects.all()) def get_artist_info2(self, request, *args, **kwargs): - artist = kwargs.pop('obj') - payload = { - 'artist-info2': {} - } + payload = {"artist-info2": {}} return response.Response(payload, status=200) - @list_route( - methods=['get', 'post'], - url_name='get_album', - url_path='getAlbum') - @find_object( - music_models.Album.objects.select_related('artist')) + @list_route(methods=["get", "post"], url_name="get_album", url_path="getAlbum") + @find_object(music_models.Album.objects.select_related("artist")) def get_album(self, request, *args, **kwargs): - album = kwargs.pop('obj') + album = kwargs.pop("obj") data = serializers.GetAlbumSerializer(album).data - payload = { - 'album': data - } + payload = {"album": data} return response.Response(payload, status=200) - @list_route( - methods=['get', 'post'], - url_name='stream', - url_path='stream') - @find_object( - music_models.Track.objects.all()) + @list_route(methods=["get", "post"], url_name="stream", url_path="stream") + @find_object(music_models.Track.objects.all()) def stream(self, request, *args, **kwargs): - track = kwargs.pop('obj') + track = kwargs.pop("obj") queryset = track.files.select_related( - 'library_track', - 'track__album__artist', - 'track__artist', + "library_track", "track__album__artist", "track__artist" ) track_file = queryset.first() if not track_file: return response.Response(status=404) return music_views.handle_serve(track_file) - @list_route( - methods=['get', 'post'], - url_name='star', - url_path='star') - @find_object( - music_models.Track.objects.all()) + @list_route(methods=["get", "post"], url_name="star", url_path="star") + @find_object(music_models.Track.objects.all()) def star(self, request, *args, **kwargs): - track = kwargs.pop('obj') + track = kwargs.pop("obj") TrackFavorite.add(user=request.user, track=track) - return response.Response({'status': 'ok'}) + return response.Response({"status": "ok"}) - @list_route( - methods=['get', 'post'], - url_name='unstar', - url_path='unstar') - @find_object( - music_models.Track.objects.all()) + @list_route(methods=["get", "post"], url_name="unstar", url_path="unstar") + @find_object(music_models.Track.objects.all()) def unstar(self, request, *args, **kwargs): - track = kwargs.pop('obj') + track = kwargs.pop("obj") request.user.track_favorites.filter(track=track).delete() - return response.Response({'status': 'ok'}) + return response.Response({"status": "ok"}) @list_route( - methods=['get', 'post'], - url_name='get_starred2', - url_path='getStarred2') + methods=["get", "post"], url_name="get_starred2", url_path="getStarred2" + ) def get_starred2(self, request, *args, **kwargs): favorites = request.user.track_favorites.all() - data = { - 'starred2': { - 'song': serializers.get_starred_tracks_data(favorites) - } - } + data = {"starred2": {"song": serializers.get_starred_tracks_data(favorites)}} return response.Response(data) - @list_route( - methods=['get', 'post'], - url_name='get_starred', - url_path='getStarred') + @list_route(methods=["get", "post"], url_name="get_starred", url_path="getStarred") def get_starred(self, request, *args, **kwargs): favorites = request.user.track_favorites.all() - data = { - 'starred': { - 'song': serializers.get_starred_tracks_data(favorites) - } - } + data = {"starred": {"song": serializers.get_starred_tracks_data(favorites)}} return response.Response(data) @list_route( - methods=['get', 'post'], - url_name='get_album_list2', - url_path='getAlbumList2') + methods=["get", "post"], url_name="get_album_list2", url_path="getAlbumList2" + ) def get_album_list2(self, request, *args, **kwargs): - queryset = music_models.Album.objects.with_tracks_count() + queryset = music_models.Album.objects.with_tracks_count().order_by( + "artist__name" + ) data = request.GET or request.POST filterset = filters.AlbumList2FilterSet(data, queryset=queryset) queryset = filterset.qs try: - offset = int(data['offset']) + offset = int(data["offset"]) except (TypeError, KeyError, ValueError): offset = 0 try: - size = int(data['size']) + size = int(data["size"]) except (TypeError, KeyError, ValueError): size = 50 size = min(size, 500) - queryset = queryset[offset:size] - data = { - 'albumList2': { - 'album': serializers.get_album_list2_data(queryset) - } - } + queryset = queryset[offset : offset + size] + data = {"albumList2": {"album": serializers.get_album_list2_data(queryset)}} return response.Response(data) - @list_route( - methods=['get', 'post'], - url_name='search3', - url_path='search3') + @list_route(methods=["get", "post"], url_name="search3", url_path="search3") def search3(self, request, *args, **kwargs): data = request.GET or request.POST - query = str(data.get('query', '')).replace('*', '') + query = str(data.get("query", "")).replace("*", "") conf = [ { - 'subsonic': 'artist', - 'search_fields': ['name'], - 'queryset': ( - music_models.Artist.objects - .with_albums_count() - .values('id', '_albums_count', 'name') + "subsonic": "artist", + "search_fields": ["name"], + "queryset": ( + music_models.Artist.objects.with_albums_count().values( + "id", "_albums_count", "name" + ) ), - 'serializer': lambda qs: [ - serializers.get_artist_data(a) for a in qs - ] + "serializer": lambda qs: [serializers.get_artist_data(a) for a in qs], }, { - 'subsonic': 'album', - 'search_fields': ['title'], - 'queryset': ( - music_models.Album.objects - .with_tracks_count() - .select_related('artist') + "subsonic": "album", + "search_fields": ["title"], + "queryset": ( + music_models.Album.objects.with_tracks_count().select_related( + "artist" + ) ), - 'serializer': serializers.get_album_list2_data, + "serializer": serializers.get_album_list2_data, }, { - 'subsonic': 'song', - 'search_fields': ['title'], - 'queryset': ( - music_models.Track.objects - .prefetch_related('files') - .select_related('album__artist') + "subsonic": "song", + "search_fields": ["title"], + "queryset": ( + music_models.Track.objects.prefetch_related("files").select_related( + "album__artist" + ) ), - 'serializer': serializers.get_song_list_data, + "serializer": serializers.get_song_list_data, }, ] - payload = { - 'searchResult3': {} - } + payload = {"searchResult3": {}} for c in conf: - offsetKey = '{}Offset'.format(c['subsonic']) - countKey = '{}Count'.format(c['subsonic']) + offsetKey = "{}Offset".format(c["subsonic"]) + countKey = "{}Count".format(c["subsonic"]) try: offset = int(data[offsetKey]) except (TypeError, KeyError, ValueError): @@ -349,60 +280,49 @@ class SubsonicViewSet(viewsets.GenericViewSet): size = 20 size = min(size, 100) - queryset = c['queryset'] + queryset = c["queryset"] if query: - queryset = c['queryset'].filter( - utils.get_query(query, c['search_fields']) + queryset = c["queryset"].filter( + utils.get_query(query, c["search_fields"]) ) - queryset = queryset[offset:size] - payload['searchResult3'][c['subsonic']] = c['serializer'](queryset) + queryset = queryset[offset : offset + size] + payload["searchResult3"][c["subsonic"]] = c["serializer"](queryset) return response.Response(payload) @list_route( - methods=['get', 'post'], - url_name='get_playlists', - url_path='getPlaylists') + methods=["get", "post"], url_name="get_playlists", url_path="getPlaylists" + ) def get_playlists(self, request, *args, **kwargs): - playlists = request.user.playlists.with_tracks_count().select_related( - 'user' - ) + playlists = request.user.playlists.with_tracks_count().select_related("user") data = { - 'playlists': { - 'playlist': [ - serializers.get_playlist_data(p) for p in playlists] + "playlists": { + "playlist": [serializers.get_playlist_data(p) for p in playlists] } } return response.Response(data) @list_route( - methods=['get', 'post'], - url_name='get_playlist', - url_path='getPlaylist') - @find_object( - playlists_models.Playlist.objects.with_tracks_count()) + methods=["get", "post"], url_name="get_playlist", url_path="getPlaylist" + ) + @find_object(playlists_models.Playlist.objects.with_tracks_count()) def get_playlist(self, request, *args, **kwargs): - playlist = kwargs.pop('obj') - data = { - 'playlist': serializers.get_playlist_detail_data(playlist) - } + playlist = kwargs.pop("obj") + data = {"playlist": serializers.get_playlist_detail_data(playlist)} return response.Response(data) @list_route( - methods=['get', 'post'], - url_name='update_playlist', - url_path='updatePlaylist') - @find_object( - lambda request: request.user.playlists.all(), - field='playlistId') + methods=["get", "post"], url_name="update_playlist", url_path="updatePlaylist" + ) + @find_object(lambda request: request.user.playlists.all(), field="playlistId") def update_playlist(self, request, *args, **kwargs): - playlist = kwargs.pop('obj') + playlist = kwargs.pop("obj") data = request.GET or request.POST - new_name = data.get('name', '') + new_name = data.get("name", "") if new_name: playlist.name = new_name - playlist.save(update_fields=['name', 'modification_date']) + playlist.save(update_fields=["name", "modification_date"]) try: - to_remove = int(data['songIndexToRemove']) + to_remove = int(data["songIndexToRemove"]) plt = playlist.playlist_tracks.get(index=to_remove) except (TypeError, ValueError, KeyError): pass @@ -412,7 +332,7 @@ class SubsonicViewSet(viewsets.GenericViewSet): plt.delete(update_indexes=True) ids = [] - for i in data.getlist('songIdToAdd'): + for i in data.getlist("songIdToAdd"): try: ids.append(int(i)) except (TypeError, ValueError): @@ -429,45 +349,38 @@ class SubsonicViewSet(viewsets.GenericViewSet): if sorted_tracks: playlist.insert_many(sorted_tracks) - data = { - 'status': 'ok' - } + data = {"status": "ok"} return response.Response(data) @list_route( - methods=['get', 'post'], - url_name='delete_playlist', - url_path='deletePlaylist') - @find_object( - lambda request: request.user.playlists.all()) + methods=["get", "post"], url_name="delete_playlist", url_path="deletePlaylist" + ) + @find_object(lambda request: request.user.playlists.all()) def delete_playlist(self, request, *args, **kwargs): - playlist = kwargs.pop('obj') + playlist = kwargs.pop("obj") playlist.delete() - data = { - 'status': 'ok' - } + data = {"status": "ok"} return response.Response(data) @list_route( - methods=['get', 'post'], - url_name='create_playlist', - url_path='createPlaylist') + methods=["get", "post"], url_name="create_playlist", url_path="createPlaylist" + ) def create_playlist(self, request, *args, **kwargs): data = request.GET or request.POST - name = data.get('name', '') + name = data.get("name", "") if not name: - return response.Response({ - 'error': { - 'code': 10, - 'message': 'Playlist ID or name must be specified.' + return response.Response( + { + "error": { + "code": 10, + "message": "Playlist ID or name must be specified.", + } } - }) + ) - playlist = request.user.playlists.create( - name=name - ) + playlist = request.user.playlists.create(name=name) ids = [] - for i in data.getlist('songId'): + for i in data.getlist("songId"): try: ids.append(int(i)) except (TypeError, ValueError): @@ -484,92 +397,67 @@ class SubsonicViewSet(viewsets.GenericViewSet): pass if sorted_tracks: playlist.insert_many(sorted_tracks) - playlist = request.user.playlists.with_tracks_count().get( - pk=playlist.pk) - data = { - 'playlist': serializers.get_playlist_detail_data(playlist) - } + playlist = request.user.playlists.with_tracks_count().get(pk=playlist.pk) + data = {"playlist": serializers.get_playlist_detail_data(playlist)} return response.Response(data) @list_route( - methods=['get', 'post'], - url_name='get_music_folders', - url_path='getMusicFolders') + methods=["get", "post"], + url_name="get_music_folders", + url_path="getMusicFolders", + ) def get_music_folders(self, request, *args, **kwargs): - data = { - 'musicFolders': { - 'musicFolder': [{ - 'id': 1, - 'name': 'Music' - }] - } - } + data = {"musicFolders": {"musicFolder": [{"id": 1, "name": "Music"}]}} return response.Response(data) @list_route( - methods=['get', 'post'], - url_name='get_cover_art', - url_path='getCoverArt') + methods=["get", "post"], url_name="get_cover_art", url_path="getCoverArt" + ) def get_cover_art(self, request, *args, **kwargs): data = request.GET or request.POST - id = data.get('id', '') + id = data.get("id", "") if not id: - return response.Response({ - 'error': { - 'code': 10, - 'message': 'cover art ID must be specified.' - } - }) + return response.Response( + {"error": {"code": 10, "message": "cover art ID must be specified."}} + ) - if id.startswith('al-'): + if id.startswith("al-"): try: - album_id = int(id.replace('al-', '')) - album = music_models.Album.objects.exclude( - cover__isnull=True - ).exclude(cover='').get(pk=album_id) + album_id = int(id.replace("al-", "")) + album = ( + music_models.Album.objects.exclude(cover__isnull=True) + .exclude(cover="") + .get(pk=album_id) + ) except (TypeError, ValueError, music_models.Album.DoesNotExist): - return response.Response({ - 'error': { - 'code': 70, - 'message': 'cover art not found.' - } - }) + return response.Response( + {"error": {"code": 70, "message": "cover art not found."}} + ) cover = album.cover else: - return response.Response({ - 'error': { - 'code': 70, - 'message': 'cover art not found.' - } - }) + return response.Response( + {"error": {"code": 70, "message": "cover art not found."}} + ) - mapping = { - 'nginx': 'X-Accel-Redirect', - 'apache2': 'X-Sendfile', - } + mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"} path = music_views.get_file_path(cover) file_header = mapping[settings.REVERSE_PROXY_TYPE] # let the proxy set the content-type - r = response.Response({}, content_type='') + r = response.Response({}, content_type="") r[file_header] = path return r - @list_route( - methods=['get', 'post'], - url_name='scrobble', - url_path='scrobble') + @list_route(methods=["get", "post"], url_name="scrobble", url_path="scrobble") def scrobble(self, request, *args, **kwargs): data = request.GET or request.POST serializer = serializers.ScrobbleSerializer( - data=data, context={'user': request.user}) + data=data, context={"user": request.user} + ) if not serializer.is_valid(): - return response.Response({ - 'error': { - 'code': 0, - 'message': 'Invalid payload' - } - }) - if serializer.validated_data['submission']: - l = serializer.save() - record.send(l) + return response.Response( + {"error": {"code": 0, "message": "Invalid payload"}} + ) + if serializer.validated_data["submission"]: + listening = serializer.save() + record.send(listening) return response.Response({}) diff --git a/api/funkwhale_api/taskapp/celery.py b/api/funkwhale_api/taskapp/celery.py index 60b09bece..98e980f07 100644 --- a/api/funkwhale_api/taskapp/celery.py +++ b/api/funkwhale_api/taskapp/celery.py @@ -1,29 +1,31 @@ from __future__ import absolute_import -import os + import functools +import os from celery import Celery from django.apps import AppConfig from django.conf import settings - if not settings.configured: # set the default Django settings module for the 'celery' program. - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") # pragma: no cover + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "config.settings.local" + ) # pragma: no cover -app = Celery('funkwhale_api') +app = Celery("funkwhale_api") class CeleryConfig(AppConfig): - name = 'funkwhale_api.taskapp' - verbose_name = 'Celery Config' + name = "funkwhale_api.taskapp" + verbose_name = "Celery Config" def ready(self): # Using a string here means the worker will not have to # pickle the object when using Windows. - app.config_from_object('django.conf:settings', namespace='CELERY') + app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks(lambda: settings.INSTALLED_APPS, force=True) @@ -31,7 +33,7 @@ def require_instance(model_or_qs, parameter_name, id_kwarg_name=None): def decorator(function): @functools.wraps(function) def inner(*args, **kwargs): - kw = id_kwarg_name or '_'.join([parameter_name, 'id']) + kw = id_kwarg_name or "_".join([parameter_name, "id"]) pk = kwargs.pop(kw) try: instance = model_or_qs.get(pk=pk) @@ -39,5 +41,7 @@ def require_instance(model_or_qs, parameter_name, id_kwarg_name=None): instance = model_or_qs.objects.get(pk=pk) kwargs[parameter_name] = instance return function(*args, **kwargs) + return inner + return decorator diff --git a/api/funkwhale_api/users/adapters.py b/api/funkwhale_api/users/adapters.py index 7bd341d14..6d8c365d5 100644 --- a/api/funkwhale_api/users/adapters.py +++ b/api/funkwhale_api/users/adapters.py @@ -1,15 +1,13 @@ -from django.conf import settings - from allauth.account.adapter import DefaultAccountAdapter +from django.conf import settings from dynamic_preferences.registries import global_preferences_registry class FunkwhaleAccountAdapter(DefaultAccountAdapter): - def is_open_for_signup(self, request): manager = global_preferences_registry.manager() - return manager['users__registration_enabled'] + return manager["users__registration_enabled"] def send_mail(self, template_prefix, email, context): - context['funkwhale_url'] = settings.FUNKWHALE_URL + context["funkwhale_url"] = settings.FUNKWHALE_URL return super().send_mail(template_prefix, email, context) diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py index cb74abf0e..5c694ab0e 100644 --- a/api/funkwhale_api/users/admin.py +++ b/api/funkwhale_api/users/admin.py @@ -17,9 +17,9 @@ class MyUserChangeForm(UserChangeForm): class MyUserCreationForm(UserCreationForm): - error_message = UserCreationForm.error_messages.update({ - 'duplicate_username': 'This username has already been taken.' - }) + error_message = UserCreationForm.error_messages.update( + {"duplicate_username": "This username has already been taken."} + ) class Meta(UserCreationForm.Meta): model = User @@ -30,7 +30,7 @@ class MyUserCreationForm(UserCreationForm): User.objects.get(username=username) except User.DoesNotExist: return username - raise forms.ValidationError(self.error_messages['duplicate_username']) + raise forms.ValidationError(self.error_messages["duplicate_username"]) @admin.register(User) @@ -38,38 +38,39 @@ class UserAdmin(AuthUserAdmin): form = MyUserChangeForm add_form = MyUserCreationForm list_display = [ - 'username', - 'email', - 'date_joined', - 'last_login', - 'is_staff', - 'is_superuser', + "username", + "email", + "date_joined", + "last_login", + "is_staff", + "is_superuser", ] list_filter = [ - 'is_superuser', - 'is_staff', - 'privacy_level', - 'permission_settings', - 'permission_library', - 'permission_federation', + "is_superuser", + "is_staff", + "privacy_level", + "permission_settings", + "permission_library", + "permission_federation", ] fieldsets = ( - (None, {'fields': ('username', 'password', 'privacy_level')}), - (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), - (_('Permissions'), { - 'fields': ( - 'is_active', - 'is_staff', - 'is_superuser', - 'permission_upload', - 'permission_library', - 'permission_settings', - 'permission_federation')}), - (_('Important dates'), {'fields': ('last_login', 'date_joined')}), - (_('Useless fields'), { - 'fields': ( - 'user_permissions', - 'groups', - )}) - ) + (None, {"fields": ("username", "password", "privacy_level")}), + (_("Personal info"), {"fields": ("first_name", "last_name", "email")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "permission_upload", + "permission_library", + "permission_settings", + "permission_federation", + ) + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + (_("Useless fields"), {"fields": ("user_permissions", "groups")}), + ) diff --git a/api/funkwhale_api/users/api_urls.py b/api/funkwhale_api/users/api_urls.py index 8aba7f1a8..267ee2d69 100644 --- a/api/funkwhale_api/users/api_urls.py +++ b/api/funkwhale_api/users/api_urls.py @@ -1,7 +1,8 @@ from rest_framework import routers + from . import views router = routers.SimpleRouter() -router.register(r'users', views.UserViewSet, 'users') +router.register(r"users", views.UserViewSet, "users") urlpatterns = router.urls diff --git a/api/funkwhale_api/users/dynamic_preferences_registry.py b/api/funkwhale_api/users/dynamic_preferences_registry.py index 7108360b9..08f5730a8 100644 --- a/api/funkwhale_api/users/dynamic_preferences_registry.py +++ b/api/funkwhale_api/users/dynamic_preferences_registry.py @@ -5,36 +5,26 @@ from funkwhale_api.common import preferences as common_preferences from . import models -users = types.Section('users') +users = types.Section("users") @global_preferences_registry.register class RegistrationEnabled(types.BooleanPreference): show_in_api = True section = users - name = 'registration_enabled' + name = "registration_enabled" default = False - verbose_name = 'Open registrations to new users' - help_text = ( - 'When enabled, new users will be able to register on this instance.' - ) + verbose_name = "Open registrations to new users" + help_text = "When enabled, new users will be able to register on this instance." @global_preferences_registry.register class DefaultPermissions(common_preferences.StringListPreference): show_in_api = True section = users - name = 'default_permissions' + name = "default_permissions" default = [] - verbose_name = 'Default permissions' - help_text = ( - 'A list of default preferences to give to all registered users.' - ) - choices = [ - (k, c['label']) - for k, c in models.PERMISSIONS_CONFIGURATION.items() - ] - field_kwargs = { - 'choices': choices, - 'required': False, - } + verbose_name = "Default permissions" + help_text = "A list of default preferences to give to all registered users." + choices = [(k, c["label"]) for k, c in models.PERMISSIONS_CONFIGURATION.items()] + field_kwargs = {"choices": choices, "required": False} diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py index cd28f4407..eed8c7175 100644 --- a/api/funkwhale_api/users/factories.py +++ b/api/funkwhale_api/users/factories.py @@ -1,15 +1,15 @@ import factory - -from funkwhale_api.factories import registry, ManyToManyFromList from django.contrib.auth.models import Permission +from funkwhale_api.factories import ManyToManyFromList, registry + @registry.register class GroupFactory(factory.django.DjangoModelFactory): - name = factory.Sequence(lambda n: 'group-{0}'.format(n)) + name = factory.Sequence(lambda n: "group-{0}".format(n)) class Meta: - model = 'auth.Group' + model = "auth.Group" @factory.post_generation def perms(self, create, extracted, **kwargs): @@ -20,8 +20,7 @@ class GroupFactory(factory.django.DjangoModelFactory): if extracted: perms = [ Permission.objects.get( - content_type__app_label=p.split('.')[0], - codename=p.split('.')[1], + content_type__app_label=p.split(".")[0], codename=p.split(".")[1] ) for p in extracted ] @@ -31,15 +30,15 @@ class GroupFactory(factory.django.DjangoModelFactory): @registry.register class UserFactory(factory.django.DjangoModelFactory): - username = factory.Sequence(lambda n: 'user-{0}'.format(n)) - email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n)) - password = factory.PostGenerationMethodCall('set_password', 'test') + username = factory.Sequence(lambda n: "user-{0}".format(n)) + email = factory.Sequence(lambda n: "user-{0}@example.com".format(n)) + password = factory.PostGenerationMethodCall("set_password", "test") subsonic_api_token = None - groups = ManyToManyFromList('groups') + groups = ManyToManyFromList("groups") class Meta: - model = 'users.User' - django_get_or_create = ('username', ) + model = "users.User" + django_get_or_create = ("username",) @factory.post_generation def perms(self, create, extracted, **kwargs): @@ -50,8 +49,7 @@ class UserFactory(factory.django.DjangoModelFactory): if extracted: perms = [ Permission.objects.get( - content_type__app_label=p.split('.')[0], - codename=p.split('.')[1], + content_type__app_label=p.split(".")[0], codename=p.split(".")[1] ) for p in extracted ] @@ -59,7 +57,7 @@ class UserFactory(factory.django.DjangoModelFactory): self.user_permissions.add(*perms) -@registry.register(name='users.SuperUser') +@registry.register(name="users.SuperUser") class SuperUserFactory(UserFactory): is_staff = True is_superuser = True diff --git a/api/funkwhale_api/users/migrations/0001_initial.py b/api/funkwhale_api/users/migrations/0001_initial.py index ef9240c91..cc8307c88 100644 --- a/api/funkwhale_api/users/migrations/0001_initial.py +++ b/api/funkwhale_api/users/migrations/0001_initial.py @@ -9,36 +9,129 @@ import django.core.validators class Migration(migrations.Migration): - dependencies = [ - ('auth', '0006_require_contenttypes_0002'), - ] + dependencies = [("auth", "0006_require_contenttypes_0002")] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('id', models.AutoField(primary_key=True, verbose_name='ID', serialize=False, auto_created=True)), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(null=True, verbose_name='last login', blank=True)), - ('is_superuser', models.BooleanField(help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status', default=False)), - ('username', models.CharField(max_length=30, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')], verbose_name='username', error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True)), - ('first_name', models.CharField(max_length=30, verbose_name='first name', blank=True)), - ('last_name', models.CharField(max_length=30, verbose_name='last name', blank=True)), - ('email', models.EmailField(max_length=254, verbose_name='email address', blank=True)), - ('is_staff', models.BooleanField(help_text='Designates whether the user can log into this admin site.', verbose_name='staff status', default=False)), - ('is_active', models.BooleanField(help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active', default=True)), - ('date_joined', models.DateTimeField(verbose_name='date joined', default=django.utils.timezone.now)), - ('groups', models.ManyToManyField(related_name='user_set', blank=True, verbose_name='groups', to='auth.Group', help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_query_name='user')), - ('user_permissions', models.ManyToManyField(related_name='user_set', blank=True, verbose_name='user permissions', to='auth.Permission', help_text='Specific permissions for this user.', related_query_name='user')), - ('name', models.CharField(max_length=255, verbose_name='Name of User', blank=True)), + ( + "id", + models.AutoField( + primary_key=True, + verbose_name="ID", + serialize=False, + auto_created=True, + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + null=True, verbose_name="last login", blank=True + ), + ), + ( + "is_superuser", + models.BooleanField( + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + default=False, + ), + ), + ( + "username", + models.CharField( + max_length=30, + validators=[ + django.core.validators.RegexValidator( + "^[\\w.@+-]+$", + "Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.", + "invalid", + ) + ], + verbose_name="username", + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.", + unique=True, + ), + ), + ( + "first_name", + models.CharField( + max_length=30, verbose_name="first name", blank=True + ), + ), + ( + "last_name", + models.CharField( + max_length=30, verbose_name="last name", blank=True + ), + ), + ( + "email", + models.EmailField( + max_length=254, verbose_name="email address", blank=True + ), + ), + ( + "is_staff", + models.BooleanField( + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + default=False, + ), + ), + ( + "is_active", + models.BooleanField( + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + default=True, + ), + ), + ( + "date_joined", + models.DateTimeField( + verbose_name="date joined", default=django.utils.timezone.now + ), + ), + ( + "groups", + models.ManyToManyField( + related_name="user_set", + blank=True, + verbose_name="groups", + to="auth.Group", + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_query_name="user", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + related_name="user_set", + blank=True, + verbose_name="user permissions", + to="auth.Permission", + help_text="Specific permissions for this user.", + related_query_name="user", + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Name of User", blank=True + ), + ), ], options={ - 'verbose_name': 'user', - 'abstract': False, - 'verbose_name_plural': 'users', + "verbose_name": "user", + "abstract": False, + "verbose_name_plural": "users", }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), + managers=[("objects", django.contrib.auth.models.UserManager())], + ) ] diff --git a/api/funkwhale_api/users/migrations/0002_auto_20171214_2205.py b/api/funkwhale_api/users/migrations/0002_auto_20171214_2205.py index 4bbbaa62b..75fc22035 100644 --- a/api/funkwhale_api/users/migrations/0002_auto_20171214_2205.py +++ b/api/funkwhale_api/users/migrations/0002_auto_20171214_2205.py @@ -9,20 +9,23 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('users', '0001_initial'), - ] + dependencies = [("users", "0001_initial")] operations = [ migrations.AlterModelManagers( - name='user', - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], + name="user", + managers=[("objects", django.contrib.auth.models.UserManager())], ), migrations.AlterField( - model_name='user', - name='username', - field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'), + model_name="user", + name="username", + field=models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], + verbose_name="username", + ), ), ] diff --git a/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py b/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py index fd75795d3..62c038b7a 100644 --- a/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py +++ b/api/funkwhale_api/users/migrations/0003_auto_20171226_1357.py @@ -6,19 +6,19 @@ import uuid class Migration(migrations.Migration): - dependencies = [ - ('users', '0002_auto_20171214_2205'), - ] + dependencies = [("users", "0002_auto_20171214_2205")] operations = [ migrations.AddField( - model_name='user', - name='secret_key', + model_name="user", + name="secret_key", field=models.UUIDField(default=uuid.uuid4, null=True), ), migrations.AlterField( - model_name='user', - name='last_name', - field=models.CharField(blank=True, max_length=150, verbose_name='last name'), + model_name="user", + name="last_name", + field=models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), ), ] diff --git a/api/funkwhale_api/users/migrations/0004_user_privacy_level.py b/api/funkwhale_api/users/migrations/0004_user_privacy_level.py index 81891eb0f..86b2c7581 100644 --- a/api/funkwhale_api/users/migrations/0004_user_privacy_level.py +++ b/api/funkwhale_api/users/migrations/0004_user_privacy_level.py @@ -5,14 +5,21 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('users', '0003_auto_20171226_1357'), - ] + dependencies = [("users", "0003_auto_20171226_1357")] operations = [ migrations.AddField( - model_name='user', - name='privacy_level', - field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30), - ), + model_name="user", + name="privacy_level", + field=models.CharField( + choices=[ + ("me", "Only me"), + ("followers", "Me and my followers"), + ("instance", "Everyone on my instance, and my followers"), + ("everyone", "Everyone, including people on other instances"), + ], + default="instance", + max_length=30, + ), + ) ] diff --git a/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py b/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py index 689b3ef77..65a1f1935 100644 --- a/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py +++ b/api/funkwhale_api/users/migrations/0005_user_subsonic_api_token.py @@ -5,14 +5,12 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('users', '0004_user_privacy_level'), - ] + dependencies = [("users", "0004_user_privacy_level")] operations = [ migrations.AddField( - model_name='user', - name='subsonic_api_token', + model_name="user", + name="subsonic_api_token", field=models.CharField(blank=True, max_length=255, null=True), - ), + ) ] diff --git a/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py b/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py index 7c9ab0fad..d5f6d911b 100644 --- a/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py +++ b/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py @@ -5,24 +5,22 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('users', '0005_user_subsonic_api_token'), - ] + dependencies = [("users", "0005_user_subsonic_api_token")] operations = [ migrations.AddField( - model_name='user', - name='permission_federation', + model_name="user", + name="permission_federation", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='user', - name='permission_library', + model_name="user", + name="permission_library", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='user', - name='permission_settings', + model_name="user", + name="permission_settings", field=models.BooleanField(default=False), ), ] diff --git a/api/funkwhale_api/users/migrations/0007_auto_20180524_2009.py b/api/funkwhale_api/users/migrations/0007_auto_20180524_2009.py index e3d582c53..218aa7e48 100644 --- a/api/funkwhale_api/users/migrations/0007_auto_20180524_2009.py +++ b/api/funkwhale_api/users/migrations/0007_auto_20180524_2009.py @@ -5,29 +5,37 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('users', '0006_auto_20180517_2324'), - ] + dependencies = [("users", "0006_auto_20180517_2324")] operations = [ migrations.AddField( - model_name='user', - name='permission_upload', - field=models.BooleanField(default=False, verbose_name='Upload new content to the library'), + model_name="user", + name="permission_upload", + field=models.BooleanField( + default=False, verbose_name="Upload new content to the library" + ), ), migrations.AlterField( - model_name='user', - name='permission_federation', - field=models.BooleanField(default=False, help_text='Follow other instances, accept/deny library follow requests...', verbose_name='Manage library federation'), + model_name="user", + name="permission_federation", + field=models.BooleanField( + default=False, + help_text="Follow other instances, accept/deny library follow requests...", + verbose_name="Manage library federation", + ), ), migrations.AlterField( - model_name='user', - name='permission_library', - field=models.BooleanField(default=False, help_text='Manage library', verbose_name='Manage library'), + model_name="user", + name="permission_library", + field=models.BooleanField( + default=False, help_text="Manage library", verbose_name="Manage library" + ), ), migrations.AlterField( - model_name='user', - name='permission_settings', - field=models.BooleanField(default=False, verbose_name='Manage instance-level settings'), + model_name="user", + name="permission_settings", + field=models.BooleanField( + default=False, verbose_name="Manage instance-level settings" + ), ), ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index fcf78d047..caf1e452b 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -1,42 +1,35 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import +from __future__ import absolute_import, unicode_literals import binascii import os import uuid from django.conf import settings -from django.contrib.auth.models import AbstractUser, Permission -from django.urls import reverse +from django.contrib.auth.models import AbstractUser from django.db import models +from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ -from funkwhale_api.common import fields -from funkwhale_api.common import preferences +from funkwhale_api.common import fields, preferences def get_token(): - return binascii.b2a_hex(os.urandom(15)).decode('utf-8') + return binascii.b2a_hex(os.urandom(15)).decode("utf-8") PERMISSIONS_CONFIGURATION = { - 'federation': { - 'label': 'Manage library federation', - 'help_text': 'Follow other instances, accept/deny library follow requests...', + "federation": { + "label": "Manage library federation", + "help_text": "Follow other instances, accept/deny library follow requests...", }, - 'library': { - 'label': 'Manage library', - 'help_text': 'Manage library, delete files, tracks, artists, albums...', - }, - 'settings': { - 'label': 'Manage instance-level settings', - 'help_text': '', - }, - 'upload': { - 'label': 'Upload new content to the library', - 'help_text': '', + "library": { + "label": "Manage library", + "help_text": "Manage library, delete files, tracks, artists, albums...", }, + "settings": {"label": "Manage instance-level settings", "help_text": ""}, + "upload": {"label": "Upload new content to the library", "help_text": ""}, } PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys()) @@ -58,51 +51,55 @@ class User(AbstractUser): # anyway since django use stronger schemes for storing passwords. # Users that want to use the subsonic API from external client # should set this token and use it as their password in such clients - subsonic_api_token = models.CharField( - blank=True, null=True, max_length=255) + subsonic_api_token = models.CharField(blank=True, null=True, max_length=255) # permissions permission_federation = models.BooleanField( - PERMISSIONS_CONFIGURATION['federation']['label'], - help_text=PERMISSIONS_CONFIGURATION['federation']['help_text'], - default=False) + PERMISSIONS_CONFIGURATION["federation"]["label"], + help_text=PERMISSIONS_CONFIGURATION["federation"]["help_text"], + default=False, + ) permission_library = models.BooleanField( - PERMISSIONS_CONFIGURATION['library']['label'], - help_text=PERMISSIONS_CONFIGURATION['library']['help_text'], - default=False) + PERMISSIONS_CONFIGURATION["library"]["label"], + help_text=PERMISSIONS_CONFIGURATION["library"]["help_text"], + default=False, + ) permission_settings = models.BooleanField( - PERMISSIONS_CONFIGURATION['settings']['label'], - help_text=PERMISSIONS_CONFIGURATION['settings']['help_text'], - default=False) + PERMISSIONS_CONFIGURATION["settings"]["label"], + help_text=PERMISSIONS_CONFIGURATION["settings"]["help_text"], + default=False, + ) permission_upload = models.BooleanField( - PERMISSIONS_CONFIGURATION['upload']['label'], - help_text=PERMISSIONS_CONFIGURATION['upload']['help_text'], - default=False) + PERMISSIONS_CONFIGURATION["upload"]["label"], + help_text=PERMISSIONS_CONFIGURATION["upload"]["help_text"], + default=False, + ) def __str__(self): return self.username def get_permissions(self): - defaults = preferences.get('users__default_permissions') + defaults = preferences.get("users__default_permissions") perms = {} for p in PERMISSIONS: v = ( - self.is_superuser or - getattr(self, 'permission_{}'.format(p)) or - p in defaults + self.is_superuser + or getattr(self, "permission_{}".format(p)) + or p in defaults ) perms[p] = v return perms - def has_permissions(self, *perms, operator='and'): - if operator not in ['and', 'or']: - raise ValueError('Invalid operator {}'.format(operator)) + def has_permissions(self, *perms, **kwargs): + operator = kwargs.pop("operator", "and") + if operator not in ["and", "or"]: + raise ValueError("Invalid operator {}".format(operator)) permissions = self.get_permissions() - checker = all if operator == 'and' else any + checker = all if operator == "and" else any return checker([permissions[p] for p in perms]) def get_absolute_url(self): - return reverse('users:detail', kwargs={'username': self.username}) + return reverse("users:detail", kwargs={"username": self.username}) def update_secret_key(self): self.secret_key = uuid.uuid4() @@ -119,4 +116,4 @@ class User(AbstractUser): self.update_subsonic_api_token() def get_activity_url(self): - return settings.FUNKWHALE_URL + '/@{}'.format(self.username) + return settings.FUNKWHALE_URL + "/@{}".format(self.username) diff --git a/api/funkwhale_api/users/permissions.py b/api/funkwhale_api/users/permissions.py index 146bc5e1c..02c1198e8 100644 --- a/api/funkwhale_api/users/permissions.py +++ b/api/funkwhale_api/users/permissions.py @@ -11,11 +11,13 @@ class HasUserPermission(BasePermission): permission_classes = [HasUserPermission] required_permissions = ['federation'] """ + def has_permission(self, request, view): - if not hasattr(request, 'user') or not request.user: + if not hasattr(request, "user") or not request.user: return False if request.user.is_anonymous: return False - operator = getattr(view, 'permission_operator', 'and') + operator = getattr(view, "permission_operator", "and") return request.user.has_permissions( - *view.required_permissions, operator=operator) + *view.required_permissions, operator=operator + ) diff --git a/api/funkwhale_api/users/rest_auth_urls.py b/api/funkwhale_api/users/rest_auth_urls.py index fa6c425cc..732a3bbbc 100644 --- a/api/funkwhale_api/users/rest_auth_urls.py +++ b/api/funkwhale_api/users/rest_auth_urls.py @@ -1,32 +1,35 @@ -from django.views.generic import TemplateView from django.conf.urls import url - -from rest_auth.registration import views as registration_views +from django.views.generic import TemplateView from rest_auth import views as rest_auth_views +from rest_auth.registration import views as registration_views from . import views - urlpatterns = [ - url(r'^$', views.RegisterView.as_view(), name='rest_register'), - url(r'^verify-email/$', + url(r"^$", views.RegisterView.as_view(), name="rest_register"), + url( + r"^verify-email/$", registration_views.VerifyEmailView.as_view(), - name='rest_verify_email'), - url(r'^change-password/$', + name="rest_verify_email", + ), + url( + r"^change-password/$", rest_auth_views.PasswordChangeView.as_view(), - name='change_password'), - + name="change_password", + ), # This url is used by django-allauth and empty TemplateView is # defined just to allow reverse() call inside app, for example when email # with verification link is being sent, then it's required to render email # content. - # account_confirm_email - You should override this view to handle it in # your API client somehow and then, send post to /verify-email/ endpoint # with proper key. # If you don't want to use API on that step, then just use ConfirmEmailView # view from: # djang-allauth https://github.com/pennersr/django-allauth/blob/master/allauth/account/views.py#L190 - url(r'^account-confirm-email/(?P\w+)/$', TemplateView.as_view(), - name='account_confirm_email'), + url( + r"^account-confirm-email/(?P\w+)/$", + TemplateView.as_view(), + name="account_confirm_email", + ), ] diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 3a095e78a..b3bd431c7 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -1,7 +1,7 @@ from django.conf import settings - -from rest_framework import serializers from rest_auth.serializers import PasswordResetSerializer as PRS +from rest_framework import serializers + from funkwhale_api.activity import serializers as activity_serializers from . import models @@ -9,35 +9,27 @@ from . import models class UserActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() - name = serializers.CharField(source='username') - local_id = serializers.CharField(source='username') + name = serializers.CharField(source="username") + local_id = serializers.CharField(source="username") class Meta: model = models.User - fields = [ - 'id', - 'local_id', - 'name', - 'type' - ] + fields = ["id", "local_id", "name", "type"] def get_type(self, obj): - return 'Person' + return "Person" class UserBasicSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ['id', 'username', 'name', 'date_joined'] + fields = ["id", "username", "name", "date_joined"] class UserWriteSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = [ - 'name', - 'privacy_level' - ] + fields = ["name", "privacy_level"] class UserReadSerializer(serializers.ModelSerializer): @@ -47,15 +39,15 @@ class UserReadSerializer(serializers.ModelSerializer): class Meta: model = models.User fields = [ - 'id', - 'username', - 'name', - 'email', - 'is_staff', - 'is_superuser', - 'permissions', - 'date_joined', - 'privacy_level', + "id", + "username", + "name", + "email", + "is_staff", + "is_superuser", + "permissions", + "date_joined", + "privacy_level", ] def get_permissions(self, o): @@ -64,8 +56,4 @@ class UserReadSerializer(serializers.ModelSerializer): class PasswordResetSerializer(PRS): def get_email_options(self): - return { - 'extra_email_context': { - 'funkwhale_url': settings.FUNKWHALE_URL - } - } + return {"extra_email_context": {"funkwhale_url": settings.FUNKWHALE_URL}} diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index 0cc317889..69e69d26e 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -1,24 +1,18 @@ -from rest_framework.response import Response -from rest_framework import mixins -from rest_framework import viewsets -from rest_framework.decorators import detail_route, list_route - -from rest_auth.registration.views import RegisterView as BaseRegisterView from allauth.account.adapter import get_adapter +from rest_auth.registration.views import RegisterView as BaseRegisterView +from rest_framework import mixins, viewsets +from rest_framework.decorators import detail_route, list_route +from rest_framework.response import Response from funkwhale_api.common import preferences -from . import models -from . import serializers +from . import models, serializers class RegisterView(BaseRegisterView): - def create(self, request, *args, **kwargs): if not self.is_open_for_signup(request): - r = { - 'detail': 'Registration has been disabled', - } + r = {"detail": "Registration has been disabled"} return Response(r, status=403) return super().create(request, *args, **kwargs) @@ -26,47 +20,42 @@ class RegisterView(BaseRegisterView): return get_adapter().is_open_for_signup(request) -class UserViewSet( - mixins.UpdateModelMixin, - viewsets.GenericViewSet): +class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): queryset = models.User.objects.all() serializer_class = serializers.UserWriteSerializer - lookup_field = 'username' + lookup_field = "username" - @list_route(methods=['get']) + @list_route(methods=["get"]) def me(self, request, *args, **kwargs): """Return information about the current user""" serializer = serializers.UserReadSerializer(request.user) return Response(serializer.data) - @detail_route( - methods=['get', 'post', 'delete'], url_path='subsonic-token') + @detail_route(methods=["get", "post", "delete"], url_path="subsonic-token") def subsonic_token(self, request, *args, **kwargs): - if not self.request.user.username == kwargs.get('username'): + if not self.request.user.username == kwargs.get("username"): return Response(status=403) - if not preferences.get('subsonic__enabled'): + if not preferences.get("subsonic__enabled"): return Response(status=405) - if request.method.lower() == 'get': - return Response({ - 'subsonic_api_token': self.request.user.subsonic_api_token - }) - if request.method.lower() == 'delete': + if request.method.lower() == "get": + return Response( + {"subsonic_api_token": self.request.user.subsonic_api_token} + ) + if request.method.lower() == "delete": self.request.user.subsonic_api_token = None - self.request.user.save(update_fields=['subsonic_api_token']) + self.request.user.save(update_fields=["subsonic_api_token"]) return Response(status=204) self.request.user.update_subsonic_api_token() - self.request.user.save(update_fields=['subsonic_api_token']) - data = { - 'subsonic_api_token': self.request.user.subsonic_api_token - } + self.request.user.save(update_fields=["subsonic_api_token"]) + data = {"subsonic_api_token": self.request.user.subsonic_api_token} return Response(data) def update(self, request, *args, **kwargs): - if not self.request.user.username == kwargs.get('username'): + if not self.request.user.username == kwargs.get("username"): return Response(status=403) return super().update(request, *args, **kwargs) def partial_update(self, request, *args, **kwargs): - if not self.request.user.username == kwargs.get('username'): + if not self.request.user.username == kwargs.get("username"): return Response(status=403) return super().partial_update(request, *args, **kwargs) diff --git a/api/requirements/local.txt b/api/requirements/local.txt index c5f2ad0b7..f11f976b8 100644 --- a/api/requirements/local.txt +++ b/api/requirements/local.txt @@ -9,3 +9,4 @@ django-debug-toolbar>=1.9,<1.10 # improved REPL ipdb==0.8.1 +black diff --git a/api/setup.cfg b/api/setup.cfg index b1267c904..18e34bc35 100644 --- a/api/setup.cfg +++ b/api/setup.cfg @@ -1,6 +1,10 @@ [flake8] max-line-length = 120 -exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,tests/data,tests/music/conftest.py +ignore = F405,W503,E203 + +[isort] +skip_glob = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules [pep8] max-line-length = 120 diff --git a/api/tests/activity/test_record.py b/api/tests/activity/test_record.py index 41846ba6f..69d3a28cf 100644 --- a/api/tests/activity/test_record.py +++ b/api/tests/activity/test_record.py @@ -1,4 +1,3 @@ -import pytest from django.db import models from rest_framework import serializers @@ -8,36 +7,35 @@ from funkwhale_api.activity import record class FakeModel(models.Model): class Meta: - app_label = 'tests' + app_label = "tests" class FakeSerializer(serializers.ModelSerializer): class Meta: model = FakeModel - fields = ['id'] - - + fields = ["id"] def test_can_bind_serializer_to_model(activity_registry): activity_registry.register_serializer(FakeSerializer) - assert activity_registry['tests.FakeModel']['serializer'] == FakeSerializer + assert activity_registry["tests.FakeModel"]["serializer"] == FakeSerializer def test_can_bind_consumer_to_model(activity_registry): activity_registry.register_serializer(FakeSerializer) - @activity_registry.register_consumer('tests.FakeModel') + + @activity_registry.register_consumer("tests.FakeModel") def propagate(data, obj): return True - assert activity_registry['tests.FakeModel']['consumers'] == [propagate] + assert activity_registry["tests.FakeModel"]["consumers"] == [propagate] def test_record_object_calls_consumer(activity_registry, mocker): activity_registry.register_serializer(FakeSerializer) stub = mocker.stub() - activity_registry.register_consumer('tests.FakeModel')(stub) + activity_registry.register_consumer("tests.FakeModel")(stub) o = FakeModel(id=1) data = FakeSerializer(o).data record.send(o) diff --git a/api/tests/activity/test_serializers.py b/api/tests/activity/test_serializers.py index 792fa74b9..2561b5c8c 100644 --- a/api/tests/activity/test_serializers.py +++ b/api/tests/activity/test_serializers.py @@ -1,12 +1,11 @@ from funkwhale_api.activity import serializers from funkwhale_api.favorites.serializers import TrackFavoriteActivitySerializer -from funkwhale_api.history.serializers import \ - ListeningActivitySerializer +from funkwhale_api.history.serializers import ListeningActivitySerializer def test_autoserializer(factories): - favorite = factories['favorites.TrackFavorite']() - listening = factories['history.Listening']() + favorite = factories["favorites.TrackFavorite"]() + listening = factories["history.Listening"]() objects = [favorite, listening] serializer = serializers.AutoSerializer(objects, many=True) expected = [ diff --git a/api/tests/activity/test_utils.py b/api/tests/activity/test_utils.py index 43bb45df8..0dabd3a28 100644 --- a/api/tests/activity/test_utils.py +++ b/api/tests/activity/test_utils.py @@ -2,20 +2,18 @@ from funkwhale_api.activity import utils def test_get_activity(factories): - user = factories['users.User']() - listening = factories['history.Listening']() - favorite = factories['favorites.TrackFavorite']() + user = factories["users.User"]() + listening = factories["history.Listening"]() + favorite = factories["favorites.TrackFavorite"]() objects = list(utils.get_activity(user)) assert objects == [favorite, listening] def test_get_activity_honors_privacy_level(factories, anonymous_user): - listening = factories['history.Listening'](user__privacy_level='me') - favorite1 = factories['favorites.TrackFavorite']( - user__privacy_level='everyone') - favorite2 = factories['favorites.TrackFavorite']( - user__privacy_level='instance') + factories["history.Listening"](user__privacy_level="me") + favorite1 = factories["favorites.TrackFavorite"](user__privacy_level="everyone") + factories["favorites.TrackFavorite"](user__privacy_level="instance") objects = list(utils.get_activity(anonymous_user)) assert objects == [favorite1] diff --git a/api/tests/activity/test_views.py b/api/tests/activity/test_views.py index 9b24f3ad3..1f5efae51 100644 --- a/api/tests/activity/test_views.py +++ b/api/tests/activity/test_views.py @@ -1,18 +1,16 @@ from django.urls import reverse -from funkwhale_api.activity import serializers -from funkwhale_api.activity import utils +from funkwhale_api.activity import serializers, utils def test_activity_view(factories, api_client, preferences, anonymous_user): - preferences['common__api_authentication_required'] = False - favorite = factories['favorites.TrackFavorite']( - user__privacy_level='everyone') - listening = factories['history.Listening']() - url = reverse('api:v1:activity-list') + preferences["common__api_authentication_required"] = False + factories["favorites.TrackFavorite"](user__privacy_level="everyone") + factories["history.Listening"]() + url = reverse("api:v1:activity-list") objects = utils.get_activity(anonymous_user) serializer = serializers.AutoSerializer(objects, many=True) response = api_client.get(url) assert response.status_code == 200 - assert response.data['results'] == serializer.data + assert response.data["results"] == serializer.data diff --git a/api/tests/channels/test_auth.py b/api/tests/channels/test_auth.py index a2b7eaf0c..505bef1c0 100644 --- a/api/tests/channels/test_auth.py +++ b/api/tests/channels/test_auth.py @@ -1,5 +1,4 @@ import pytest - from rest_framework_jwt.settings import api_settings from funkwhale_api.common.auth import TokenAuthMiddleware @@ -8,30 +7,24 @@ jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER -@pytest.mark.parametrize('query_string', [ - b'token=wrong', - b'', -]) +@pytest.mark.parametrize("query_string", [b"token=wrong", b""]) def test_header_anonymous(query_string, factories): def callback(scope): - assert scope['user'].is_anonymous + assert scope["user"].is_anonymous - scope = { - 'query_string': query_string - } + scope = {"query_string": query_string} consumer = TokenAuthMiddleware(callback) consumer(scope) def test_header_correct_token(factories): - user = factories['users.User']() + user = factories["users.User"]() payload = jwt_payload_handler(user) token = jwt_encode_handler(payload) - def callback(scope): - assert scope['user'] == user - scope = { - 'query_string': 'token={}'.format(token).encode('utf-8') - } + def callback(scope): + assert scope["user"] == user + + scope = {"query_string": "token={}".format(token).encode("utf-8")} consumer = TokenAuthMiddleware(callback) consumer(scope) diff --git a/api/tests/channels/test_consumers.py b/api/tests/channels/test_consumers.py index f1648efb3..f4f5f3ad6 100644 --- a/api/tests/channels/test_consumers.py +++ b/api/tests/channels/test_consumers.py @@ -2,15 +2,15 @@ from funkwhale_api.common import consumers def test_auth_consumer_requires_valid_user(mocker): - m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close') - scope = {'user': None} + m = mocker.patch("funkwhale_api.common.consumers.JsonAuthConsumer.close") + scope = {"user": None} consumer = consumers.JsonAuthConsumer(scope=scope) consumer.connect() m.assert_called_once_with() def test_auth_consumer_requires_user_in_scope(mocker): - m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close') + m = mocker.patch("funkwhale_api.common.consumers.JsonAuthConsumer.close") scope = {} consumer = consumers.JsonAuthConsumer(scope=scope) consumer.connect() @@ -18,9 +18,9 @@ def test_auth_consumer_requires_user_in_scope(mocker): def test_auth_consumer_accepts_connection(mocker, factories): - user = factories['users.User']() - m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.accept') - scope = {'user': user} + user = factories["users.User"]() + m = mocker.patch("funkwhale_api.common.consumers.JsonAuthConsumer.accept") + scope = {"user": user} consumer = consumers.JsonAuthConsumer(scope=scope) consumer.connect() m.assert_called_once_with() diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py index 29a8fb05c..d26923148 100644 --- a/api/tests/common/test_fields.py +++ b/api/tests/common/test_fields.py @@ -1,5 +1,4 @@ import pytest - from django.contrib.auth.models import AnonymousUser from django.db.models import Q @@ -7,11 +6,16 @@ from funkwhale_api.common import fields from funkwhale_api.users.factories import UserFactory -@pytest.mark.parametrize('user,expected', [ - (AnonymousUser(), Q(privacy_level='everyone')), - (UserFactory.build(pk=1), - Q(privacy_level__in=['followers', 'instance', 'everyone'])), -]) -def test_privacy_level_query(user,expected): +@pytest.mark.parametrize( + "user,expected", + [ + (AnonymousUser(), Q(privacy_level="everyone")), + ( + UserFactory.build(pk=1), + Q(privacy_level__in=["followers", "instance", "everyone"]), + ), + ], +) +def test_privacy_level_query(user, expected): query = fields.privacy_level_query(user) assert query == expected diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py index f04f12e0b..bf4d8bde5 100644 --- a/api/tests/common/test_permissions.py +++ b/api/tests/common/test_permissions.py @@ -1,43 +1,41 @@ import pytest - -from rest_framework.views import APIView - from django.http import Http404 +from rest_framework.views import APIView from funkwhale_api.common import permissions def test_owner_permission_owner_field_ok(nodb_factories, api_request): - playlist = nodb_factories['playlists.Playlist']() + playlist = nodb_factories["playlists.Playlist"]() view = APIView.as_view() permission = permissions.OwnerPermission() - request = api_request.get('/') - setattr(request, 'user', playlist.user) + request = api_request.get("/") + setattr(request, "user", playlist.user) check = permission.has_object_permission(request, view, playlist) assert check is True def test_owner_permission_owner_field_not_ok( - anonymous_user, nodb_factories, api_request): - playlist = nodb_factories['playlists.Playlist']() + anonymous_user, nodb_factories, api_request +): + playlist = nodb_factories["playlists.Playlist"]() view = APIView.as_view() permission = permissions.OwnerPermission() - request = api_request.get('/') - setattr(request, 'user', anonymous_user) + request = api_request.get("/") + setattr(request, "user", anonymous_user) with pytest.raises(Http404): permission.has_object_permission(request, view, playlist) -def test_owner_permission_read_only( - anonymous_user, nodb_factories, api_request): - playlist = nodb_factories['playlists.Playlist']() +def test_owner_permission_read_only(anonymous_user, nodb_factories, api_request): + playlist = nodb_factories["playlists.Playlist"]() view = APIView.as_view() - setattr(view, 'owner_checks', ['write']) + setattr(view, "owner_checks", ["write"]) permission = permissions.OwnerPermission() - request = api_request.get('/') - setattr(request, 'user', anonymous_user) + request = api_request.get("/") + setattr(request, "user", anonymous_user) check = permission.has_object_permission(request, view, playlist) assert check is True diff --git a/api/tests/common/test_preferences.py b/api/tests/common/test_preferences.py index 475610a93..7f941a450 100644 --- a/api/tests/common/test_preferences.py +++ b/api/tests/common/test_preferences.py @@ -1,44 +1,45 @@ import pytest - from dynamic_preferences.registries import global_preferences_registry + from funkwhale_api.common import preferences as common_preferences @pytest.fixture def string_list_pref(preferences): - @global_preferences_registry.register class P(common_preferences.StringListPreference): - default = ['hello'] - section = 'test' - name = 'string_list' + default = ["hello"] + section = "test" + name = "string_list" + yield - del global_preferences_registry['test']['string_list'] + del global_preferences_registry["test"]["string_list"] -@pytest.mark.parametrize('input,output', [ - (['a', 'b', 'c'], 'a,b,c'), - (['a', 'c', 'b'], 'a,b,c'), - (('a', 'c', 'b'), 'a,b,c'), - ([], None), -]) +@pytest.mark.parametrize( + "input,output", + [ + (["a", "b", "c"], "a,b,c"), + (["a", "c", "b"], "a,b,c"), + (("a", "c", "b"), "a,b,c"), + ([], None), + ], +) def test_string_list_serializer_to_db(input, output): - s = common_preferences.StringListSerializer.to_db(input) == output + common_preferences.StringListSerializer.to_db(input) == output -@pytest.mark.parametrize('input,output', [ - ('a,b,c', ['a', 'b', 'c'], ), - (None, []), - ('', []), -]) +@pytest.mark.parametrize( + "input,output", [("a,b,c", ["a", "b", "c"]), (None, []), ("", [])] +) def test_string_list_serializer_to_python(input, output): - s = common_preferences.StringListSerializer.to_python(input) == output + common_preferences.StringListSerializer.to_python(input) == output def test_string_list_pref_default(string_list_pref, preferences): - assert preferences['test__string_list'] == ['hello'] + assert preferences["test__string_list"] == ["hello"] def test_string_list_pref_set(string_list_pref, preferences): - preferences['test__string_list'] = ['world', 'hello'] - assert preferences['test__string_list'] == ['hello', 'world'] + preferences["test__string_list"] = ["world", "hello"] + assert preferences["test__string_list"] == ["hello", "world"] diff --git a/api/tests/common/test_scripts.py b/api/tests/common/test_scripts.py index ce478ba04..40d9ea0a7 100644 --- a/api/tests/common/test_scripts.py +++ b/api/tests/common/test_scripts.py @@ -1,7 +1,7 @@ import pytest -from funkwhale_api.common.management.commands import script from funkwhale_api.common import scripts +from funkwhale_api.common.management.commands import script @pytest.fixture @@ -9,38 +9,26 @@ def command(): return script.Command() -@pytest.mark.parametrize('script_name', [ - 'django_permissions_to_user_permissions', - 'test', -]) +@pytest.mark.parametrize( + "script_name", ["django_permissions_to_user_permissions", "test"] +) def test_script_command_list(command, script_name, mocker): - mocked = mocker.patch( - 'funkwhale_api.common.scripts.{}.main'.format(script_name)) + mocked = mocker.patch("funkwhale_api.common.scripts.{}.main".format(script_name)) command.handle(script_name=script_name, interactive=False) - mocked.assert_called_once_with( - command, script_name=script_name, interactive=False) + mocked.assert_called_once_with(command, script_name=script_name, interactive=False) def test_django_permissions_to_user_permissions(factories, command): - group = factories['auth.Group']( + group = factories["auth.Group"](perms=["federation.change_library"]) + user1 = factories["users.User"]( perms=[ - 'federation.change_library' + "dynamic_preferences.change_globalpreferencemodel", + "music.add_importbatch", ] ) - user1 = factories['users.User']( - perms=[ - 'dynamic_preferences.change_globalpreferencemodel', - 'music.add_importbatch', - ] - ) - user2 = factories['users.User']( - perms=[ - 'music.add_importbatch', - ], - groups=[group] - ) + user2 = factories["users.User"](perms=["music.add_importbatch"], groups=[group]) scripts.django_permissions_to_user_permissions.main(command) diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py index f0f5fb7e6..ca5e5ad8f 100644 --- a/api/tests/common/test_serializers.py +++ b/api/tests/common/test_serializers.py @@ -7,20 +7,20 @@ from funkwhale_api.users import models class TestActionFilterSet(django_filters.FilterSet): class Meta: model = models.User - fields = ['is_active'] + fields = ["is_active"] class TestSerializer(serializers.ActionSerializer): - actions = ['test'] + actions = ["test"] filterset_class = TestActionFilterSet def handle_test(self, objects): - return {'hello': 'world'} + return {"hello": "world"} class TestDangerousSerializer(serializers.ActionSerializer): - actions = ['test', 'test_dangerous'] - dangerous_actions = ['test_dangerous'] + actions = ["test", "test_dangerous"] + dangerous_actions = ["test_dangerous"] def handle_test(self, objects): pass @@ -30,107 +30,88 @@ class TestDangerousSerializer(serializers.ActionSerializer): def test_action_serializer_validates_action(): - data = {'objects': 'all', 'action': 'nope'} + data = {"objects": "all", "action": "nope"} serializer = TestSerializer(data, queryset=models.User.objects.none()) assert serializer.is_valid() is False - assert 'action' in serializer.errors + assert "action" in serializer.errors def test_action_serializer_validates_objects(): - data = {'objects': 'nope', 'action': 'test'} + data = {"objects": "nope", "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.none()) assert serializer.is_valid() is False - assert 'objects' in serializer.errors + assert "objects" in serializer.errors def test_action_serializers_objects_clean_ids(factories): - user1 = factories['users.User']() - user2 = factories['users.User']() + user1 = factories["users.User"]() + factories["users.User"]() - data = {'objects': [user1.pk], 'action': 'test'} + data = {"objects": [user1.pk], "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.all()) assert serializer.is_valid() is True - assert list(serializer.validated_data['objects']) == [user1] + assert list(serializer.validated_data["objects"]) == [user1] def test_action_serializers_objects_clean_all(factories): - user1 = factories['users.User']() - user2 = factories['users.User']() + user1 = factories["users.User"]() + user2 = factories["users.User"]() - data = {'objects': 'all', 'action': 'test'} + data = {"objects": "all", "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.all()) assert serializer.is_valid() is True - assert list(serializer.validated_data['objects']) == [user1, user2] + assert list(serializer.validated_data["objects"]) == [user1, user2] def test_action_serializers_save(factories, mocker): - handler = mocker.spy(TestSerializer, 'handle_test') - user1 = factories['users.User']() - user2 = factories['users.User']() + handler = mocker.spy(TestSerializer, "handle_test") + factories["users.User"]() + factories["users.User"]() - data = {'objects': 'all', 'action': 'test'} + data = {"objects": "all", "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.all()) assert serializer.is_valid() is True result = serializer.save() - assert result == { - 'updated': 2, - 'action': 'test', - 'result': {'hello': 'world'}, - } + assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}} handler.assert_called_once() def test_action_serializers_filterset(factories): - user1 = factories['users.User'](is_active=False) - user2 = factories['users.User'](is_active=True) + factories["users.User"](is_active=False) + user2 = factories["users.User"](is_active=True) - data = { - 'objects': 'all', - 'action': 'test', - 'filters': {'is_active': True}, - } + data = {"objects": "all", "action": "test", "filters": {"is_active": True}} serializer = TestSerializer(data, queryset=models.User.objects.all()) assert serializer.is_valid() is True - assert list(serializer.validated_data['objects']) == [user2] + assert list(serializer.validated_data["objects"]) == [user2] def test_action_serializers_validates_at_least_one_object(): - data = { - 'objects': 'all', - 'action': 'test', - } + data = {"objects": "all", "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.none()) assert serializer.is_valid() is False - assert 'non_field_errors' in serializer.errors + assert "non_field_errors" in serializer.errors def test_dangerous_actions_refuses_all(factories): - factories['users.User']() - data = { - 'objects': 'all', - 'action': 'test_dangerous', - } - serializer = TestDangerousSerializer( - data, queryset=models.User.objects.all()) + factories["users.User"]() + data = {"objects": "all", "action": "test_dangerous"} + serializer = TestDangerousSerializer(data, queryset=models.User.objects.all()) assert serializer.is_valid() is False - assert 'non_field_errors' in serializer.errors + assert "non_field_errors" in serializer.errors def test_dangerous_actions_refuses_not_listed(factories): - factories['users.User']() - data = { - 'objects': 'all', - 'action': 'test', - } - serializer = TestDangerousSerializer( - data, queryset=models.User.objects.all()) + factories["users.User"]() + data = {"objects": "all", "action": "test"} + serializer = TestDangerousSerializer(data, queryset=models.User.objects.all()) assert serializer.is_valid() is True diff --git a/api/tests/common/test_session.py b/api/tests/common/test_session.py index 7ff1e660b..531543455 100644 --- a/api/tests/common/test_session.py +++ b/api/tests/common/test_session.py @@ -1,18 +1,16 @@ import funkwhale_api - from funkwhale_api.common import session def test_get_user_agent(settings): - settings.FUNKWHALE_URL = 'https://test.com' - 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)' - expected = 'python-requests (funkwhale/{}; +{})'.format( - funkwhale_api.__version__, - settings.FUNKWHALE_URL + settings.FUNKWHALE_URL = "https://test.com" + "http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)" + expected = "python-requests (funkwhale/{}; +{})".format( + funkwhale_api.__version__, settings.FUNKWHALE_URL ) assert session.get_user_agent() == expected def test_get_session(): expected = session.get_user_agent() - assert session.get_session().headers['User-Agent'] == expected + assert session.get_session().headers["User-Agent"] == expected diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 7caff2009..40203ee3d 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,29 +1,26 @@ import datetime -import factory -import pytest -import requests_mock import shutil import tempfile +import factory +import pytest +import requests_mock from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache from django.test import client - from dynamic_preferences.registries import global_preferences_registry - from rest_framework import fields as rest_fields -from rest_framework.test import APIClient -from rest_framework.test import APIRequestFactory +from rest_framework.test import APIClient, APIRequestFactory from funkwhale_api.activity import record from funkwhale_api.users.permissions import HasUserPermission -from funkwhale_api.taskapp import celery @pytest.fixture(scope="session", autouse=True) def factories_autodiscover(): from django.apps import apps from funkwhale_api import factories + app_names = [app.name for app in apps.app_configs.values()] factories.registry.autodiscover(app_names) @@ -44,6 +41,7 @@ def factories(db): users.User or music.Track """ from funkwhale_api import factories + for v in factories.registry.values(): try: v._meta.strategy = factory.CREATE_STRATEGY @@ -60,6 +58,7 @@ def nodb_factories(): that does not require access to the database """ from funkwhale_api import factories + for v in factories.registry.values(): try: v._meta.strategy = factory.BUILD_STRATEGY @@ -104,11 +103,11 @@ def logged_in_client(db, factories, client): Returns a logged-in, non-API client with an authenticated ``User`` stored in the ``user`` attribute """ - user = factories['users.User']() - assert client.login(username=user.username, password='test') - setattr(client, 'user', user) + user = factories["users.User"]() + assert client.login(username=user.username, password="test") + setattr(client, "user", user) yield client - delattr(client, 'user') + delattr(client, "user") @pytest.fixture @@ -131,12 +130,12 @@ def logged_in_api_client(db, factories, api_client): Return a logged-in API client with an authenticated ``User`` stored in the ``user`` attribute """ - user = factories['users.User']() - assert api_client.login(username=user.username, password='test') + user = factories["users.User"]() + assert api_client.login(username=user.username, password="test") api_client.force_authenticate(user=user) - setattr(api_client, 'user', user) + setattr(api_client, "user", user) yield api_client - delattr(api_client, 'user') + delattr(api_client, "user") @pytest.fixture @@ -145,11 +144,11 @@ def superuser_api_client(db, factories, api_client): Return a logged-in API client with an authenticated superuser stored in the ``user`` attribute """ - user = factories['users.SuperUser']() - assert api_client.login(username=user.username, password='test') - setattr(api_client, 'user', user) + user = factories["users.SuperUser"]() + assert api_client.login(username=user.username, password="test") + setattr(api_client, "user", user) yield api_client - delattr(api_client, 'user') + delattr(api_client, "user") @pytest.fixture @@ -158,11 +157,11 @@ def superuser_client(db, factories, client): Return a logged-in, non-API client with an authenticated ``User`` stored in the ``user`` attribute """ - user = factories['users.SuperUser']() - assert client.login(username=user.username, password='test') - setattr(client, 'user', user) + user = factories["users.SuperUser"]() + assert client.login(username=user.username, password="test") + setattr(client, "user", user) yield client - delattr(client, 'user') + delattr(client, "user") @pytest.fixture @@ -183,7 +182,6 @@ def fake_request(): @pytest.fixture def activity_registry(): - r = record.registry state = list(record.registry.items()) yield record.registry record.registry.clear() @@ -193,7 +191,7 @@ def activity_registry(): @pytest.fixture def activity_muted(activity_registry, mocker): - yield mocker.patch.object(record, 'send') + yield mocker.patch.object(record, "send") @pytest.fixture(autouse=True) @@ -222,19 +220,21 @@ def authenticated_actor(factories, mocker): """ Returns an authenticated ActivityPub actor """ - actor = factories['federation.Actor']() + actor = factories["federation.Actor"]() mocker.patch( - 'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor', - return_value=actor) + "funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor", + return_value=actor, + ) yield actor @pytest.fixture def assert_user_permission(): - def inner(view, permissions, operator='and'): + def inner(view, permissions, operator="and"): assert HasUserPermission in view.permission_classes - assert getattr(view, 'permission_operator', 'and') == operator + assert getattr(view, "permission_operator", "and") == operator assert set(view.required_permissions) == set(permissions) + return inner @@ -247,5 +247,6 @@ def to_api_date(): if isinstance(value, datetime.date): f = rest_fields.DateField() return f.to_representation(value) - raise ValueError('Invalid value: {}'.format(value)) + raise ValueError("Invalid value: {}".format(value)) + return inner diff --git a/api/tests/data/youtube.py b/api/tests/data/youtube.py index a8372d4c9..9c8f9e68f 100644 --- a/api/tests/data/youtube.py +++ b/api/tests/data/youtube.py @@ -1,25 +1,17 @@ - - search = {} -search['8 bit adventure'] = { - "pageInfo": { - "totalResults": 1000000, - "resultsPerPage": 25 - }, +search["8 bit adventure"] = { + "pageInfo": {"totalResults": 1000000, "resultsPerPage": 25}, "nextPageToken": "CBkQAA", - "etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/1L34zetsKWv-raAFiz0MuT0SsfQ\"", + "etag": '"gMxXHe-zinKdE9lTnzKu8vjcmDI/1L34zetsKWv-raAFiz0MuT0SsfQ"', "items": [ { - "id": { - "videoId": "0HxZn6CzOIo", - "kind": "youtube#video" - }, - "etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/GxK-wHBWUYfrJsd1dijBPTufrVE\"", + "id": {"videoId": "0HxZn6CzOIo", "kind": "youtube#video"}, + "etag": '"gMxXHe-zinKdE9lTnzKu8vjcmDI/GxK-wHBWUYfrJsd1dijBPTufrVE"', "snippet": { "liveBroadcastContent": "none", - "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...", + "description": "Description", "channelId": "UCps63j3krzAG4OyXeEyuhFw", "title": "AdhesiveWombat - 8 Bit Adventure", "channelTitle": "AdhesiveWombat", @@ -28,28 +20,25 @@ search['8 bit adventure'] = { "medium": { "url": "https://i.ytimg.com/vi/0HxZn6CzOIo/mqdefault.jpg", "height": 180, - "width": 320 + "width": 320, }, "high": { "url": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg", "height": 360, - "width": 480 + "width": 480, }, "default": { "url": "https://i.ytimg.com/vi/0HxZn6CzOIo/default.jpg", "height": 90, - "width": 120 - } - } + "width": 120, + }, + }, }, - "kind": "youtube#searchResult" + "kind": "youtube#searchResult", }, { - "id": { - "videoId": "n4A_F5SXmgo", - "kind": "youtube#video" - }, - "etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/aRVESw24jlgiErDgJKxNrazKRDc\"", + "id": {"videoId": "n4A_F5SXmgo", "kind": "youtube#video"}, + "etag": '"gMxXHe-zinKdE9lTnzKu8vjcmDI/aRVESw24jlgiErDgJKxNrazKRDc"', "snippet": { "liveBroadcastContent": "none", "description": "Free Download: http://bit.ly/1fZ1pMJ I don't post 8 bit'ish music much but damn I must admit this is goood! Enjoy \u2665 \u25bbSpikedGrin: ...", @@ -61,34 +50,31 @@ search['8 bit adventure'] = { "medium": { "url": "https://i.ytimg.com/vi/n4A_F5SXmgo/mqdefault.jpg", "height": 180, - "width": 320 + "width": 320, }, "high": { "url": "https://i.ytimg.com/vi/n4A_F5SXmgo/hqdefault.jpg", "height": 360, - "width": 480 + "width": 480, }, "default": { "url": "https://i.ytimg.com/vi/n4A_F5SXmgo/default.jpg", "height": 90, - "width": 120 - } - } + "width": 120, + }, + }, }, - "kind": "youtube#searchResult" + "kind": "youtube#searchResult", }, ], "regionCode": "FR", - "kind": "youtube#searchListResponse" + "kind": "youtube#searchListResponse", } -search['system of a down toxicity'] = { +search["system of a down toxicity"] = { "items": [ { - "id": { - "kind": "youtube#video", - "videoId": "BorYwGi2SJc" - }, + "id": {"kind": "youtube#video", "videoId": "BorYwGi2SJc"}, "kind": "youtube#searchResult", "snippet": { "title": "System of a Down: Toxicity", @@ -98,30 +84,27 @@ search['system of a down toxicity'] = { "default": { "height": 90, "width": 120, - "url": "https://i.ytimg.com/vi/BorYwGi2SJc/default.jpg" + "url": "https://i.ytimg.com/vi/BorYwGi2SJc/default.jpg", }, "high": { "height": 360, "width": 480, - "url": "https://i.ytimg.com/vi/BorYwGi2SJc/hqdefault.jpg" + "url": "https://i.ytimg.com/vi/BorYwGi2SJc/hqdefault.jpg", }, "medium": { "height": 180, "width": 320, - "url": "https://i.ytimg.com/vi/BorYwGi2SJc/mqdefault.jpg" - } + "url": "https://i.ytimg.com/vi/BorYwGi2SJc/mqdefault.jpg", + }, }, "publishedAt": "2007-12-17T12:39:54.000Z", "description": "http://www.vedrescsaba.uw.hu The System of a Down song Toxicity arranged for a classical piano quintet, played by Vedres Csaba and the Kairosz quartet.", - "liveBroadcastContent": "none" + "liveBroadcastContent": "none", }, - "etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/UwR8H6P6kbijNZmBNkYd2jAzDnI\"" + "etag": '"gMxXHe-zinKdE9lTnzKu8vjcmDI/UwR8H6P6kbijNZmBNkYd2jAzDnI"', }, { - "id": { - "kind": "youtube#video", - "videoId": "ENBv2i88g6Y" - }, + "id": {"kind": "youtube#video", "videoId": "ENBv2i88g6Y"}, "kind": "youtube#searchResult", "snippet": { "title": "System Of A Down - Question!", @@ -131,32 +114,29 @@ search['system of a down toxicity'] = { "default": { "height": 90, "width": 120, - "url": "https://i.ytimg.com/vi/ENBv2i88g6Y/default.jpg" + "url": "https://i.ytimg.com/vi/ENBv2i88g6Y/default.jpg", }, "high": { "height": 360, "width": 480, - "url": "https://i.ytimg.com/vi/ENBv2i88g6Y/hqdefault.jpg" + "url": "https://i.ytimg.com/vi/ENBv2i88g6Y/hqdefault.jpg", }, "medium": { "height": 180, "width": 320, - "url": "https://i.ytimg.com/vi/ENBv2i88g6Y/mqdefault.jpg" - } + "url": "https://i.ytimg.com/vi/ENBv2i88g6Y/mqdefault.jpg", + }, }, "publishedAt": "2009-10-03T04:49:03.000Z", "description": "System of a Down's official music video for 'Question!'. Click to listen to System of a Down on Spotify: http://smarturl.it/SystemSpotify?IQid=SystemQu As featured ...", - "liveBroadcastContent": "none" + "liveBroadcastContent": "none", }, - "etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/dB-M0N9mB4xE-k4yAF_4d8aU0I4\"" + "etag": '"gMxXHe-zinKdE9lTnzKu8vjcmDI/dB-M0N9mB4xE-k4yAF_4d8aU0I4"', }, ], - "etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/yhLQgSpeObNnybd5JqSzlGiJ8Ew\"", + "etag": '"gMxXHe-zinKdE9lTnzKu8vjcmDI/yhLQgSpeObNnybd5JqSzlGiJ8Ew"', "nextPageToken": "CBkQAA", - "pageInfo": { - "resultsPerPage": 25, - "totalResults": 26825 - }, + "pageInfo": {"resultsPerPage": 25, "totalResults": 26825}, "kind": "youtube#searchListResponse", - "regionCode": "FR" + "regionCode": "FR", } diff --git a/api/tests/favorites/test_activity.py b/api/tests/favorites/test_activity.py index 63174f9e2..e4c040b20 100644 --- a/api/tests/favorites/test_activity.py +++ b/api/tests/favorites/test_activity.py @@ -1,19 +1,17 @@ -from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.favorites import activities, serializers from funkwhale_api.music.serializers import TrackActivitySerializer -from funkwhale_api.favorites import serializers -from funkwhale_api.favorites import activities +from funkwhale_api.users.serializers import UserActivitySerializer def test_get_favorite_activity_url(settings, factories): - favorite = factories['favorites.TrackFavorite']() + favorite = factories["favorites.TrackFavorite"]() user_url = favorite.user.get_activity_url() - expected = '{}/favorites/tracks/{}'.format( - user_url, favorite.pk) + expected = "{}/favorites/tracks/{}".format(user_url, favorite.pk) assert favorite.get_activity_url() == expected def test_activity_favorite_serializer(factories): - favorite = factories['favorites.TrackFavorite']() + favorite = factories["favorites.TrackFavorite"]() actor = UserActivitySerializer(favorite.user).data field = serializers.serializers.DateTimeField() @@ -32,44 +30,30 @@ def test_activity_favorite_serializer(factories): def test_track_favorite_serializer_is_connected(activity_registry): - conf = activity_registry['favorites.TrackFavorite'] - assert conf['serializer'] == serializers.TrackFavoriteActivitySerializer + conf = activity_registry["favorites.TrackFavorite"] + assert conf["serializer"] == serializers.TrackFavoriteActivitySerializer -def test_track_favorite_serializer_instance_activity_consumer( - activity_registry): - conf = activity_registry['favorites.TrackFavorite'] +def test_track_favorite_serializer_instance_activity_consumer(activity_registry): + conf = activity_registry["favorites.TrackFavorite"] consumer = activities.broadcast_track_favorite_to_instance_activity - assert consumer in conf['consumers'] + assert consumer in conf["consumers"] -def test_broadcast_track_favorite_to_instance_activity( - factories, mocker): - p = mocker.patch('funkwhale_api.common.channels.group_send') - favorite = factories['favorites.TrackFavorite']() +def test_broadcast_track_favorite_to_instance_activity(factories, mocker): + p = mocker.patch("funkwhale_api.common.channels.group_send") + favorite = factories["favorites.TrackFavorite"]() data = serializers.TrackFavoriteActivitySerializer(favorite).data consumer = activities.broadcast_track_favorite_to_instance_activity - message = { - "type": 'event.send', - "text": '', - "data": data - } + message = {"type": "event.send", "text": "", "data": data} consumer(data=data, obj=favorite) - p.assert_called_once_with('instance_activity', message) + p.assert_called_once_with("instance_activity", message) -def test_broadcast_track_favorite_to_instance_activity_private( - factories, mocker): - p = mocker.patch('funkwhale_api.common.channels.group_send') - favorite = factories['favorites.TrackFavorite']( - user__privacy_level='me' - ) +def test_broadcast_track_favorite_to_instance_activity_private(factories, mocker): + p = mocker.patch("funkwhale_api.common.channels.group_send") + favorite = factories["favorites.TrackFavorite"](user__privacy_level="me") data = serializers.TrackFavoriteActivitySerializer(favorite).data consumer = activities.broadcast_track_favorite_to_instance_activity - message = { - "type": 'event.send', - "text": '', - "data": data - } consumer(data=data, obj=favorite) p.assert_not_called() diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index 591fe7c9c..cd75b0d26 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -1,15 +1,14 @@ import json + import pytest from django.urls import reverse -from funkwhale_api.music.models import Track, Artist from funkwhale_api.favorites.models import TrackFavorite - def test_user_can_add_favorite(factories): - track = factories['music.Track']() - user = factories['users.User']() + track = factories["music.Track"]() + user = factories["users.User"]() f = TrackFavorite.add(track, user) assert f.track == track @@ -17,35 +16,34 @@ def test_user_can_add_favorite(factories): def test_user_can_get_his_favorites(factories, logged_in_client, client): - favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user) - url = reverse('api:v1:favorites:tracks-list') + favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) + url = reverse("api:v1:favorites:tracks-list") response = logged_in_client.get(url) expected = [ { - 'track': favorite.track.pk, - 'id': favorite.id, - 'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'), + "track": favorite.track.pk, + "id": favorite.id, + "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"), } ] - parsed_json = json.loads(response.content.decode('utf-8')) + parsed_json = json.loads(response.content.decode("utf-8")) - assert expected == parsed_json['results'] + assert expected == parsed_json["results"] -def test_user_can_add_favorite_via_api( - factories, logged_in_client, activity_muted): - track = factories['music.Track']() - url = reverse('api:v1:favorites:tracks-list') - response = logged_in_client.post(url, {'track': track.pk}) +def test_user_can_add_favorite_via_api(factories, logged_in_client, activity_muted): + track = factories["music.Track"]() + url = reverse("api:v1:favorites:tracks-list") + response = logged_in_client.post(url, {"track": track.pk}) - favorite = TrackFavorite.objects.latest('id') + favorite = TrackFavorite.objects.latest("id") expected = { - 'track': track.pk, - 'id': favorite.id, - 'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'), + "track": track.pk, + "id": favorite.id, + "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"), } - parsed_json = json.loads(response.content.decode('utf-8')) + parsed_json = json.loads(response.content.decode("utf-8")) assert expected == parsed_json assert favorite.track == track @@ -53,18 +51,19 @@ def test_user_can_add_favorite_via_api( def test_adding_favorites_calls_activity_record( - factories, logged_in_client, activity_muted): - track = factories['music.Track']() - url = reverse('api:v1:favorites:tracks-list') - response = logged_in_client.post(url, {'track': track.pk}) + factories, logged_in_client, activity_muted +): + track = factories["music.Track"]() + url = reverse("api:v1:favorites:tracks-list") + response = logged_in_client.post(url, {"track": track.pk}) - favorite = TrackFavorite.objects.latest('id') + favorite = TrackFavorite.objects.latest("id") expected = { - 'track': track.pk, - 'id': favorite.id, - 'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'), + "track": track.pk, + "id": favorite.id, + "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"), } - parsed_json = json.loads(response.content.decode('utf-8')) + parsed_json = json.loads(response.content.decode("utf-8")) assert expected == parsed_json assert favorite.track == track @@ -74,44 +73,42 @@ def test_adding_favorites_calls_activity_record( def test_user_can_remove_favorite_via_api(logged_in_client, factories, client): - favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user) - url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk}) - response = client.delete(url, {'track': favorite.track.pk}) + favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) + url = reverse("api:v1:favorites:tracks-detail", kwargs={"pk": favorite.pk}) + response = client.delete(url, {"track": favorite.track.pk}) assert response.status_code == 204 assert TrackFavorite.objects.count() == 0 -@pytest.mark.parametrize('method', ['delete', 'post']) +@pytest.mark.parametrize("method", ["delete", "post"]) def test_user_can_remove_favorite_via_api_using_track_id( - method, factories, logged_in_client): - favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user) + method, factories, logged_in_client +): + favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) - url = reverse('api:v1:favorites:tracks-remove') + url = reverse("api:v1:favorites:tracks-remove") response = getattr(logged_in_client, method)( - url, json.dumps({'track': favorite.track.pk}), - content_type='application/json' + url, json.dumps({"track": favorite.track.pk}), content_type="application/json" ) assert response.status_code == 204 assert TrackFavorite.objects.count() == 0 -@pytest.mark.parametrize('url,method', [ - ('api:v1:favorites:tracks-list', 'get'), -]) +@pytest.mark.parametrize("url,method", [("api:v1:favorites:tracks-list", "get")]) def test_url_require_auth(url, method, db, preferences, client): - preferences['common__api_authentication_required'] = True + preferences["common__api_authentication_required"] = True url = reverse(url) response = getattr(client, method)(url) assert response.status_code == 401 def test_can_filter_tracks_by_favorites(factories, logged_in_client): - favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user) + favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) - url = reverse('api:v1:tracks-list') - response = logged_in_client.get(url, data={'favorites': True}) + url = reverse("api:v1:tracks-list") + response = logged_in_client.get(url, data={"favorites": True}) - parsed_json = json.loads(response.content.decode('utf-8')) - assert parsed_json['count'] == 1 - assert parsed_json['results'][0]['id'] == favorite.track.id + parsed_json = json.loads(response.content.decode("utf-8")) + assert parsed_json["count"] == 1 + assert parsed_json["results"][0]["id"] == favorite.track.id diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index c2673ff3b..9c7bb70ec 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -1,46 +1,37 @@ -import uuid -from funkwhale_api.federation import activity -from funkwhale_api.federation import serializers +from funkwhale_api.federation import activity, serializers def test_deliver(factories, r_mock, mocker, settings): settings.CELERY_TASK_ALWAYS_EAGER = True - to = factories['federation.Actor']() - mocker.patch( - 'funkwhale_api.federation.actors.get_actor', - return_value=to) - sender = factories['federation.Actor']() + to = factories["federation.Actor"]() + mocker.patch("funkwhale_api.federation.actors.get_actor", return_value=to) + sender = factories["federation.Actor"]() ac = { - 'id': 'http://test.federation/activity', - 'type': 'Create', - 'actor': sender.url, - 'object': { - 'id': 'http://test.federation/note', - 'type': 'Note', - 'content': 'Hello', - } + "id": "http://test.federation/activity", + "type": "Create", + "actor": sender.url, + "object": { + "id": "http://test.federation/note", + "type": "Note", + "content": "Hello", + }, } r_mock.post(to.inbox_url) - activity.deliver( - ac, - to=[to.url], - on_behalf_of=sender, - ) + activity.deliver(ac, to=[to.url], on_behalf_of=sender) request = r_mock.request_history[0] assert r_mock.called is True assert r_mock.call_count == 1 assert request.url == to.inbox_url - assert request.headers['content-type'] == 'application/activity+json' + assert request.headers["content-type"] == "application/activity+json" def test_accept_follow(mocker, factories): - deliver = mocker.patch( - 'funkwhale_api.federation.activity.deliver') - follow = factories['federation.Follow'](approved=None) + deliver = mocker.patch("funkwhale_api.federation.activity.deliver") + follow = factories["federation.Follow"](approved=None) expected_accept = serializers.AcceptFollowSerializer(follow).data activity.accept_follow(follow) deliver.assert_called_once_with( diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py index 6f73a9b9b..736ec8bf2 100644 --- a/api/tests/federation/test_actors.py +++ b/api/tests/federation/test_actors.py @@ -1,28 +1,21 @@ import arrow import pytest -import uuid - from django.urls import reverse from django.utils import timezone - from rest_framework import exceptions -from funkwhale_api.federation import activity -from funkwhale_api.federation import actors -from funkwhale_api.federation import models -from funkwhale_api.federation import serializers -from funkwhale_api.federation import utils +from funkwhale_api.federation import actors, models, serializers, utils from funkwhale_api.music import models as music_models from funkwhale_api.music import tasks as music_tasks def test_actor_fetching(r_mock): payload = { - 'id': 'https://actor.mock/users/actor#main-key', - 'owner': 'test', - 'publicKeyPem': 'test_pem', + "id": "https://actor.mock/users/actor#main-key", + "owner": "test", + "publicKeyPem": "test_pem", } - actor_url = 'https://actor.mock/' + actor_url = "https://actor.mock/" r_mock.get(actor_url, json=payload) r = actors.get_actor_data(actor_url) @@ -30,7 +23,7 @@ def test_actor_fetching(r_mock): def test_get_actor(factories, r_mock): - actor = factories['federation.Actor'].build() + actor = factories["federation.Actor"].build() payload = serializers.ActorSerializer(actor).data r_mock.get(actor.url, json=payload) new_actor = actors.get_actor(actor.url) @@ -40,9 +33,9 @@ def test_get_actor(factories, r_mock): def test_get_actor_use_existing(factories, preferences, mocker): - preferences['federation__actor_fetch_delay'] = 60 - actor = factories['federation.Actor']() - get_data = mocker.patch('funkwhale_api.federation.actors.get_actor_data') + preferences["federation__actor_fetch_delay"] = 60 + actor = factories["federation.Actor"]() + get_data = mocker.patch("funkwhale_api.federation.actors.get_actor_data") new_actor = actors.get_actor(actor.url) assert new_actor == actor @@ -50,87 +43,81 @@ def test_get_actor_use_existing(factories, preferences, mocker): def test_get_actor_refresh(factories, preferences, mocker): - preferences['federation__actor_fetch_delay'] = 0 - actor = factories['federation.Actor']() + preferences["federation__actor_fetch_delay"] = 0 + actor = factories["federation.Actor"]() payload = serializers.ActorSerializer(actor).data # actor changed their username in the meantime - payload['preferredUsername'] = 'New me' - get_data = mocker.patch( - 'funkwhale_api.federation.actors.get_actor_data', - return_value=payload) + payload["preferredUsername"] = "New me" + mocker.patch("funkwhale_api.federation.actors.get_actor_data", return_value=payload) new_actor = actors.get_actor(actor.url) assert new_actor == actor assert new_actor.last_fetch_date > actor.last_fetch_date - assert new_actor.preferred_username == 'New me' + assert new_actor.preferred_username == "New me" def test_get_library(db, settings, mocker): - get_key_pair = mocker.patch( - 'funkwhale_api.federation.keys.get_key_pair', - return_value=(b'private', b'public')) + mocker.patch( + "funkwhale_api.federation.keys.get_key_pair", + return_value=(b"private", b"public"), + ) expected = { - 'preferred_username': 'library', - 'domain': settings.FEDERATION_HOSTNAME, - 'type': 'Person', - 'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME), - 'manually_approves_followers': True, - 'public_key': 'public', - 'url': utils.full_url( - reverse( - 'federation:instance-actors-detail', - kwargs={'actor': 'library'})), - 'shared_inbox_url': utils.full_url( - reverse( - 'federation:instance-actors-inbox', - kwargs={'actor': 'library'})), - 'inbox_url': utils.full_url( - reverse( - 'federation:instance-actors-inbox', - kwargs={'actor': 'library'})), - 'outbox_url': utils.full_url( - reverse( - 'federation:instance-actors-outbox', - kwargs={'actor': 'library'})), - 'summary': 'Bot account to federate with {}\'s library'.format( - settings.FEDERATION_HOSTNAME), + "preferred_username": "library", + "domain": settings.FEDERATION_HOSTNAME, + "type": "Person", + "name": "{}'s library".format(settings.FEDERATION_HOSTNAME), + "manually_approves_followers": True, + "public_key": "public", + "url": utils.full_url( + reverse("federation:instance-actors-detail", kwargs={"actor": "library"}) + ), + "shared_inbox_url": utils.full_url( + reverse("federation:instance-actors-inbox", kwargs={"actor": "library"}) + ), + "inbox_url": utils.full_url( + reverse("federation:instance-actors-inbox", kwargs={"actor": "library"}) + ), + "outbox_url": utils.full_url( + reverse("federation:instance-actors-outbox", kwargs={"actor": "library"}) + ), + "summary": "Bot account to federate with {}'s library".format( + settings.FEDERATION_HOSTNAME + ), } - actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() for key, value in expected.items(): assert getattr(actor, key) == value def test_get_test(db, mocker, settings): - get_key_pair = mocker.patch( - 'funkwhale_api.federation.keys.get_key_pair', - return_value=(b'private', b'public')) + mocker.patch( + "funkwhale_api.federation.keys.get_key_pair", + return_value=(b"private", b"public"), + ) expected = { - 'preferred_username': 'test', - 'domain': settings.FEDERATION_HOSTNAME, - 'type': 'Person', - 'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME), - 'manually_approves_followers': False, - 'public_key': 'public', - 'url': utils.full_url( - reverse( - 'federation:instance-actors-detail', - kwargs={'actor': 'test'})), - 'shared_inbox_url': utils.full_url( - reverse( - 'federation:instance-actors-inbox', - kwargs={'actor': 'test'})), - 'inbox_url': utils.full_url( - reverse( - 'federation:instance-actors-inbox', - kwargs={'actor': 'test'})), - 'outbox_url': utils.full_url( - reverse( - 'federation:instance-actors-outbox', - kwargs={'actor': 'test'})), - 'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format( - settings.FEDERATION_HOSTNAME), + "preferred_username": "test", + "domain": settings.FEDERATION_HOSTNAME, + "type": "Person", + "name": "{}'s test account".format(settings.FEDERATION_HOSTNAME), + "manually_approves_followers": False, + "public_key": "public", + "url": utils.full_url( + reverse("federation:instance-actors-detail", kwargs={"actor": "test"}) + ), + "shared_inbox_url": utils.full_url( + reverse("federation:instance-actors-inbox", kwargs={"actor": "test"}) + ), + "inbox_url": utils.full_url( + reverse("federation:instance-actors-inbox", kwargs={"actor": "test"}) + ), + "outbox_url": utils.full_url( + reverse("federation:instance-actors-outbox", kwargs={"actor": "test"}) + ), + "summary": "Bot account to test federation with {}. Send me /ping and I'll answer you.".format( + settings.FEDERATION_HOSTNAME + ), } - actor = actors.SYSTEM_ACTORS['test'].get_actor_instance() + actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() for key, value in expected.items(): assert getattr(actor, key) == value @@ -140,233 +127,208 @@ def test_test_get_outbox(): "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", - {} + {}, ], "id": utils.full_url( - reverse( - 'federation:instance-actors-outbox', - kwargs={'actor': 'test'})), + reverse("federation:instance-actors-outbox", kwargs={"actor": "test"}) + ), "type": "OrderedCollection", "totalItems": 0, - "orderedItems": [] + "orderedItems": [], } - data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None) + data = actors.SYSTEM_ACTORS["test"].get_outbox({}, actor=None) assert data == expected def test_test_post_inbox_requires_authenticated_actor(): with pytest.raises(exceptions.PermissionDenied): - actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None) + actors.SYSTEM_ACTORS["test"].post_inbox({}, actor=None) def test_test_post_outbox_validates_actor(nodb_factories): - actor = nodb_factories['federation.Actor']() - data = { - 'actor': 'noop' - } + actor = nodb_factories["federation.Actor"]() + data = {"actor": "noop"} with pytest.raises(exceptions.ValidationError) as exc_info: - actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor) - msg = 'The actor making the request do not match' + actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) + msg = "The actor making the request do not match" assert msg in exc_info.value -def test_test_post_inbox_handles_create_note( - settings, mocker, factories): - deliver = mocker.patch( - 'funkwhale_api.federation.activity.deliver') - actor = factories['federation.Actor']() +def test_test_post_inbox_handles_create_note(settings, mocker, factories): + deliver = mocker.patch("funkwhale_api.federation.activity.deliver") + actor = factories["federation.Actor"]() now = timezone.now() - mocker.patch('django.utils.timezone.now', return_value=now) + mocker.patch("django.utils.timezone.now", return_value=now) data = { - 'actor': actor.url, - 'type': 'Create', - 'id': 'http://test.federation/activity', - 'object': { - 'type': 'Note', - 'id': 'http://test.federation/object', - 'content': '

@mention /ping

' - } + "actor": actor.url, + "type": "Create", + "id": "http://test.federation/activity", + "object": { + "type": "Note", + "id": "http://test.federation/object", + "content": "

@mention /ping

", + }, } - test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance() - expected_note = factories['federation.Note']( - id='https://test.federation/activities/note/{}'.format( - now.timestamp() - ), - content='Pong!', + test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() + expected_note = factories["federation.Note"]( + id="https://test.federation/activities/note/{}".format(now.timestamp()), + content="Pong!", published=now.isoformat(), - inReplyTo=data['object']['id'], + inReplyTo=data["object"]["id"], cc=[], summary=None, sensitive=False, attributedTo=test_actor.url, attachment=[], to=[actor.url], - url='https://{}/activities/note/{}'.format( + url="https://{}/activities/note/{}".format( settings.FEDERATION_HOSTNAME, now.timestamp() ), - tag=[{ - 'href': actor.url, - 'name': actor.mention_username, - 'type': 'Mention', - }] + tag=[{"href": actor.url, "name": actor.mention_username, "type": "Mention"}], ) expected_activity = { - '@context': serializers.AP_CONTEXT, - 'actor': test_actor.url, - 'id': 'https://{}/activities/note/{}/activity'.format( + "@context": serializers.AP_CONTEXT, + "actor": test_actor.url, + "id": "https://{}/activities/note/{}/activity".format( settings.FEDERATION_HOSTNAME, now.timestamp() ), - 'to': actor.url, - 'type': 'Create', - 'published': now.isoformat(), - 'object': expected_note, - 'cc': [], + "to": actor.url, + "type": "Create", + "published": now.isoformat(), + "object": expected_note, + "cc": [], } - actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor) + actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) deliver.assert_called_once_with( expected_activity, to=[actor.url], - on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance() + on_behalf_of=actors.SYSTEM_ACTORS["test"].get_actor_instance(), ) def test_getting_actor_instance_persists_in_db(db): - test = actors.SYSTEM_ACTORS['test'].get_actor_instance() + test = actors.SYSTEM_ACTORS["test"].get_actor_instance() from_db = models.Actor.objects.get(url=test.url) for f in test._meta.fields: assert getattr(from_db, f.name) == getattr(test, f.name) -@pytest.mark.parametrize('username,domain,expected', [ - ('test', 'wrongdomain.com', False), - ('notsystem', '', False), - ('test', '', True), -]) -def test_actor_is_system( - username, domain, expected, nodb_factories, settings): +@pytest.mark.parametrize( + "username,domain,expected", + [("test", "wrongdomain.com", False), ("notsystem", "", False), ("test", "", True)], +) +def test_actor_is_system(username, domain, expected, nodb_factories, settings): if not domain: domain = settings.FEDERATION_HOSTNAME - actor = nodb_factories['federation.Actor']( - preferred_username=username, - domain=domain, + actor = nodb_factories["federation.Actor"]( + preferred_username=username, domain=domain ) assert actor.is_system is expected -@pytest.mark.parametrize('username,domain,expected', [ - ('test', 'wrongdomain.com', None), - ('notsystem', '', None), - ('test', '', actors.SYSTEM_ACTORS['test']), -]) -def test_actor_is_system( - username, domain, expected, nodb_factories, settings): +@pytest.mark.parametrize( + "username,domain,expected", + [ + ("test", "wrongdomain.com", None), + ("notsystem", "", None), + ("test", "", actors.SYSTEM_ACTORS["test"]), + ], +) +def test_actor_system_conf(username, domain, expected, nodb_factories, settings): if not domain: domain = settings.FEDERATION_HOSTNAME - actor = nodb_factories['federation.Actor']( - preferred_username=username, - domain=domain, + actor = nodb_factories["federation.Actor"]( + preferred_username=username, domain=domain ) assert actor.system_conf == expected -@pytest.mark.parametrize('value', [False, True]) -def test_library_actor_manually_approves_based_on_preference( - value, preferences): - preferences['federation__music_needs_approval'] = value - library_conf = actors.SYSTEM_ACTORS['library'] +@pytest.mark.parametrize("value", [False, True]) +def test_library_actor_manually_approves_based_on_preference(value, preferences): + preferences["federation__music_needs_approval"] = value + library_conf = actors.SYSTEM_ACTORS["library"] assert library_conf.manually_approves_followers is value def test_system_actor_handle(mocker, nodb_factories): - handler = mocker.patch( - 'funkwhale_api.federation.actors.TestActor.handle_create') - actor = nodb_factories['federation.Actor']() - activity = nodb_factories['federation.Activity']( - type='Create', actor=actor.url) - serializer = serializers.ActivitySerializer( - data=activity - ) + handler = mocker.patch("funkwhale_api.federation.actors.TestActor.handle_create") + actor = nodb_factories["federation.Actor"]() + activity = nodb_factories["federation.Activity"](type="Create", actor=actor.url) + serializer = serializers.ActivitySerializer(data=activity) assert serializer.is_valid() - actors.SYSTEM_ACTORS['test'].handle(activity, actor) + actors.SYSTEM_ACTORS["test"].handle(activity, actor) handler.assert_called_once_with(activity, actor) -def test_test_actor_handles_follow( - settings, mocker, factories): - deliver = mocker.patch( - 'funkwhale_api.federation.activity.deliver') - actor = factories['federation.Actor']() - accept_follow = mocker.patch( - 'funkwhale_api.federation.activity.accept_follow') - test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance() +def test_test_actor_handles_follow(settings, mocker, factories): + deliver = mocker.patch("funkwhale_api.federation.activity.deliver") + actor = factories["federation.Actor"]() + accept_follow = mocker.patch("funkwhale_api.federation.activity.accept_follow") + test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() data = { - 'actor': actor.url, - 'type': 'Follow', - 'id': 'http://test.federation/user#follows/267', - 'object': test_actor.url, + "actor": actor.url, + "type": "Follow", + "id": "http://test.federation/user#follows/267", + "object": test_actor.url, } - actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor) + actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor) follow = models.Follow.objects.get(target=test_actor, approved=True) follow_back = models.Follow.objects.get(actor=test_actor, approved=None) accept_follow.assert_called_once_with(follow) deliver.assert_called_once_with( serializers.FollowSerializer(follow_back).data, on_behalf_of=test_actor, - to=[actor.url] + to=[actor.url], ) -def test_test_actor_handles_undo_follow( - settings, mocker, factories): - deliver = mocker.patch( - 'funkwhale_api.federation.activity.deliver') - test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance() - follow = factories['federation.Follow'](target=test_actor) - reverse_follow = factories['federation.Follow']( - actor=test_actor, target=follow.actor) +def test_test_actor_handles_undo_follow(settings, mocker, factories): + deliver = mocker.patch("funkwhale_api.federation.activity.deliver") + test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance() + follow = factories["federation.Follow"](target=test_actor) + reverse_follow = factories["federation.Follow"]( + actor=test_actor, target=follow.actor + ) follow_serializer = serializers.FollowSerializer(follow) - reverse_follow_serializer = serializers.FollowSerializer( - reverse_follow) + reverse_follow_serializer = serializers.FollowSerializer(reverse_follow) undo = { - '@context': serializers.AP_CONTEXT, - 'type': 'Undo', - 'id': follow_serializer.data['id'] + '/undo', - 'actor': follow.actor.url, - 'object': follow_serializer.data, + "@context": serializers.AP_CONTEXT, + "type": "Undo", + "id": follow_serializer.data["id"] + "/undo", + "actor": follow.actor.url, + "object": follow_serializer.data, } expected_undo = { - '@context': serializers.AP_CONTEXT, - 'type': 'Undo', - 'id': reverse_follow_serializer.data['id'] + '/undo', - 'actor': reverse_follow.actor.url, - 'object': reverse_follow_serializer.data, + "@context": serializers.AP_CONTEXT, + "type": "Undo", + "id": reverse_follow_serializer.data["id"] + "/undo", + "actor": reverse_follow.actor.url, + "object": reverse_follow_serializer.data, } - actors.SYSTEM_ACTORS['test'].post_inbox(undo, actor=follow.actor) + actors.SYSTEM_ACTORS["test"].post_inbox(undo, actor=follow.actor) deliver.assert_called_once_with( - expected_undo, - to=[follow.actor.url], - on_behalf_of=test_actor,) + expected_undo, to=[follow.actor.url], on_behalf_of=test_actor + ) assert models.Follow.objects.count() == 0 -def test_library_actor_handles_follow_manual_approval( - preferences, mocker, factories): - preferences['federation__music_needs_approval'] = True - actor = factories['federation.Actor']() +def test_library_actor_handles_follow_manual_approval(preferences, mocker, factories): + preferences["federation__music_needs_approval"] = True + actor = factories["federation.Actor"]() now = timezone.now() - mocker.patch('django.utils.timezone.now', return_value=now) - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + mocker.patch("django.utils.timezone.now", return_value=now) + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() data = { - 'actor': actor.url, - 'type': 'Follow', - 'id': 'http://test.federation/user#follows/267', - 'object': library_actor.url, + "actor": actor.url, + "type": "Follow", + "id": "http://test.federation/user#follows/267", + "object": library_actor.url, } library_actor.system_conf.post_inbox(data, actor=actor) @@ -376,18 +338,16 @@ def test_library_actor_handles_follow_manual_approval( assert follow.approved is None -def test_library_actor_handles_follow_auto_approval( - preferences, mocker, factories): - preferences['federation__music_needs_approval'] = False - actor = factories['federation.Actor']() - accept_follow = mocker.patch( - 'funkwhale_api.federation.activity.accept_follow') - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() +def test_library_actor_handles_follow_auto_approval(preferences, mocker, factories): + preferences["federation__music_needs_approval"] = False + actor = factories["federation.Actor"]() + mocker.patch("funkwhale_api.federation.activity.accept_follow") + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() data = { - 'actor': actor.url, - 'type': 'Follow', - 'id': 'http://test.federation/user#follows/267', - 'object': library_actor.url, + "actor": actor.url, + "type": "Follow", + "id": "http://test.federation/user#follows/267", + "object": library_actor.url, } library_actor.system_conf.post_inbox(data, actor=actor) @@ -397,14 +357,11 @@ def test_library_actor_handles_follow_auto_approval( assert follow.approved is True -def test_library_actor_handles_accept( - mocker, factories): - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - actor = factories['federation.Actor']() - pending_follow = factories['federation.Follow']( - actor=library_actor, - target=actor, - approved=None, +def test_library_actor_handles_accept(mocker, factories): + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + actor = factories["federation.Actor"]() + pending_follow = factories["federation.Follow"]( + actor=library_actor, target=actor, approved=None ) serializer = serializers.AcceptFollowSerializer(pending_follow) library_actor.system_conf.post_inbox(serializer.data, actor=actor) @@ -418,19 +375,19 @@ def test_library_actor_handle_create_audio_no_library(mocker, factories): # when we receive inbox create audio, we should not do anything # if we don't have a configured library matching the sender mocked_create = mocker.patch( - 'funkwhale_api.federation.serializers.AudioSerializer.create' + "funkwhale_api.federation.serializers.AudioSerializer.create" ) - actor = factories['federation.Actor']() - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + actor = factories["federation.Actor"]() + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() data = { - 'actor': actor.url, - 'type': 'Create', - 'id': 'http://test.federation/audio/create', - 'object': { - 'id': 'https://batch.import', - 'type': 'Collection', - 'totalItems': 2, - 'items': factories['federation.Audio'].create_batch(size=2) + "actor": actor.url, + "type": "Create", + "id": "http://test.federation/audio/create", + "object": { + "id": "https://batch.import", + "type": "Collection", + "totalItems": 2, + "items": factories["federation.Audio"].create_batch(size=2), }, } library_actor.system_conf.post_inbox(data, actor=actor) @@ -439,26 +396,24 @@ def test_library_actor_handle_create_audio_no_library(mocker, factories): models.LibraryTrack.objects.count() == 0 -def test_library_actor_handle_create_audio_no_library_enabled( - mocker, factories): +def test_library_actor_handle_create_audio_no_library_enabled(mocker, factories): # when we receive inbox create audio, we should not do anything # if we don't have an enabled library mocked_create = mocker.patch( - 'funkwhale_api.federation.serializers.AudioSerializer.create' + "funkwhale_api.federation.serializers.AudioSerializer.create" ) - disabled_library = factories['federation.Library']( - federation_enabled=False) + disabled_library = factories["federation.Library"](federation_enabled=False) actor = disabled_library.actor - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() data = { - 'actor': actor.url, - 'type': 'Create', - 'id': 'http://test.federation/audio/create', - 'object': { - 'id': 'https://batch.import', - 'type': 'Collection', - 'totalItems': 2, - 'items': factories['federation.Audio'].create_batch(size=2) + "actor": actor.url, + "type": "Create", + "id": "http://test.federation/audio/create", + "object": { + "id": "https://batch.import", + "type": "Collection", + "totalItems": 2, + "items": factories["federation.Audio"].create_batch(size=2), }, } library_actor.system_conf.post_inbox(data, actor=actor) @@ -468,97 +423,91 @@ def test_library_actor_handle_create_audio_no_library_enabled( def test_library_actor_handle_create_audio(mocker, factories): - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - remote_library = factories['federation.Library']( - federation_enabled=True - ) + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + remote_library = factories["federation.Library"](federation_enabled=True) data = { - 'actor': remote_library.actor.url, - 'type': 'Create', - 'id': 'http://test.federation/audio/create', - 'object': { - 'id': 'https://batch.import', - 'type': 'Collection', - 'totalItems': 2, - 'items': factories['federation.Audio'].create_batch(size=2) + "actor": remote_library.actor.url, + "type": "Create", + "id": "http://test.federation/audio/create", + "object": { + "id": "https://batch.import", + "type": "Collection", + "totalItems": 2, + "items": factories["federation.Audio"].create_batch(size=2), }, } library_actor.system_conf.post_inbox(data, actor=remote_library.actor) - lts = list(remote_library.tracks.order_by('id')) + lts = list(remote_library.tracks.order_by("id")) assert len(lts) == 2 - for i, a in enumerate(data['object']['items']): + for i, a in enumerate(data["object"]["items"]): lt = lts[i] assert lt.pk is not None - assert lt.url == a['id'] + assert lt.url == a["id"] assert lt.library == remote_library - assert lt.audio_url == a['url']['href'] - assert lt.audio_mimetype == a['url']['mediaType'] - assert lt.metadata == a['metadata'] - assert lt.title == a['metadata']['recording']['title'] - assert lt.artist_name == a['metadata']['artist']['name'] - assert lt.album_title == a['metadata']['release']['title'] - assert lt.published_date == arrow.get(a['published']) + assert lt.audio_url == a["url"]["href"] + assert lt.audio_mimetype == a["url"]["mediaType"] + assert lt.metadata == a["metadata"] + assert lt.title == a["metadata"]["recording"]["title"] + assert lt.artist_name == a["metadata"]["artist"]["name"] + assert lt.album_title == a["metadata"]["release"]["title"] + assert lt.published_date == arrow.get(a["published"]) def test_library_actor_handle_create_audio_autoimport(mocker, factories): - mocked_import = mocker.patch( - 'funkwhale_api.common.utils.on_commit') - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - remote_library = factories['federation.Library']( - federation_enabled=True, - autoimport=True, + mocked_import = mocker.patch("funkwhale_api.common.utils.on_commit") + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + remote_library = factories["federation.Library"]( + federation_enabled=True, autoimport=True ) data = { - 'actor': remote_library.actor.url, - 'type': 'Create', - 'id': 'http://test.federation/audio/create', - 'object': { - 'id': 'https://batch.import', - 'type': 'Collection', - 'totalItems': 2, - 'items': factories['federation.Audio'].create_batch(size=2) + "actor": remote_library.actor.url, + "type": "Create", + "id": "http://test.federation/audio/create", + "object": { + "id": "https://batch.import", + "type": "Collection", + "totalItems": 2, + "items": factories["federation.Audio"].create_batch(size=2), }, } library_actor.system_conf.post_inbox(data, actor=remote_library.actor) - lts = list(remote_library.tracks.order_by('id')) + lts = list(remote_library.tracks.order_by("id")) assert len(lts) == 2 - for i, a in enumerate(data['object']['items']): + for i, a in enumerate(data["object"]["items"]): lt = lts[i] assert lt.pk is not None - assert lt.url == a['id'] + assert lt.url == a["id"] assert lt.library == remote_library - assert lt.audio_url == a['url']['href'] - assert lt.audio_mimetype == a['url']['mediaType'] - assert lt.metadata == a['metadata'] - assert lt.title == a['metadata']['recording']['title'] - assert lt.artist_name == a['metadata']['artist']['name'] - assert lt.album_title == a['metadata']['release']['title'] - assert lt.published_date == arrow.get(a['published']) + assert lt.audio_url == a["url"]["href"] + assert lt.audio_mimetype == a["url"]["mediaType"] + assert lt.metadata == a["metadata"] + assert lt.title == a["metadata"]["recording"]["title"] + assert lt.artist_name == a["metadata"]["artist"]["name"] + assert lt.album_title == a["metadata"]["release"]["title"] + assert lt.published_date == arrow.get(a["published"]) - batch = music_models.ImportBatch.objects.latest('id') + batch = music_models.ImportBatch.objects.latest("id") assert batch.jobs.count() == len(lts) - assert batch.source == 'federation' + assert batch.source == "federation" assert batch.submitted_by is None - for i, job in enumerate(batch.jobs.order_by('id')): + for i, job in enumerate(batch.jobs.order_by("id")): lt = lts[i] assert job.library_track == lt assert job.mbid == lt.mbid assert job.source == lt.url mocked_import.assert_any_call( - music_tasks.import_job_run.delay, - import_job_id=job.pk, - use_acoustid=False, + music_tasks.import_job_run.delay, import_job_id=job.pk, use_acoustid=False ) diff --git a/api/tests/federation/test_authentication.py b/api/tests/federation/test_authentication.py index 2f69e4d4f..95cec5d2a 100644 --- a/api/tests/federation/test_authentication.py +++ b/api/tests/federation/test_authentication.py @@ -1,38 +1,33 @@ -from funkwhale_api.federation import authentication -from funkwhale_api.federation import keys -from funkwhale_api.federation import signing +from funkwhale_api.federation import authentication, keys def test_authenticate(factories, mocker, api_request): private, public = keys.get_key_pair() - actor_url = 'https://test.federation/actor' + actor_url = "https://test.federation/actor" mocker.patch( - 'funkwhale_api.federation.actors.get_actor_data', + "funkwhale_api.federation.actors.get_actor_data", return_value={ - 'id': actor_url, - 'type': 'Person', - 'outbox': 'https://test.com', - 'inbox': 'https://test.com', - 'preferredUsername': 'test', - 'publicKey': { - 'publicKeyPem': public.decode('utf-8'), - 'owner': actor_url, - 'id': actor_url + '#main-key', - } - }) - signed_request = factories['federation.SignedRequest']( - auth__key=private, - auth__key_id=actor_url + '#main-key', - auth__headers=[ - 'date', - ] + "id": actor_url, + "type": "Person", + "outbox": "https://test.com", + "inbox": "https://test.com", + "preferredUsername": "test", + "publicKey": { + "publicKeyPem": public.decode("utf-8"), + "owner": actor_url, + "id": actor_url + "#main-key", + }, + }, + ) + signed_request = factories["federation.SignedRequest"]( + auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"] ) prepared = signed_request.prepare() django_request = api_request.get( - '/', + "/", **{ - 'HTTP_DATE': prepared.headers['date'], - 'HTTP_SIGNATURE': prepared.headers['signature'], + "HTTP_DATE": prepared.headers["date"], + "HTTP_SIGNATURE": prepared.headers["signature"], } ) authenticator = authentication.SignatureAuthentication() @@ -40,5 +35,5 @@ def test_authenticate(factories, mocker, api_request): actor = django_request.actor assert user.is_anonymous is True - assert actor.public_key == public.decode('utf-8') + assert actor.public_key == public.decode("utf-8") assert actor.url == actor_url diff --git a/api/tests/federation/test_keys.py b/api/tests/federation/test_keys.py index 9dd71be09..0f6158680 100644 --- a/api/tests/federation/test_keys.py +++ b/api/tests/federation/test_keys.py @@ -3,23 +3,29 @@ import pytest from funkwhale_api.federation import keys -@pytest.mark.parametrize('raw, expected', [ - ('algorithm="test",keyId="https://test.com"', 'https://test.com'), - ('keyId="https://test.com",algorithm="test"', 'https://test.com'), -]) +@pytest.mark.parametrize( + "raw, expected", + [ + ('algorithm="test",keyId="https://test.com"', "https://test.com"), + ('keyId="https://test.com",algorithm="test"', "https://test.com"), + ], +) def test_get_key_from_header(raw, expected): r = keys.get_key_id_from_signature_header(raw) assert r == expected -@pytest.mark.parametrize('raw', [ - 'algorithm="test",keyid="badCase"', - 'algorithm="test",wrong="wrong"', - 'keyId = "wrong"', - 'keyId=\'wrong\'', - 'keyId="notanurl"', - 'keyId="wrong://test.com"', -]) +@pytest.mark.parametrize( + "raw", + [ + 'algorithm="test",keyid="badCase"', + 'algorithm="test",wrong="wrong"', + 'keyId = "wrong"', + "keyId='wrong'", + 'keyId="notanurl"', + 'keyId="wrong://test.com"', + ], +) def test_get_key_from_header_invalid(raw): with pytest.raises(ValueError): keys.get_key_id_from_signature_header(raw) diff --git a/api/tests/federation/test_library.py b/api/tests/federation/test_library.py index 7a3abf5d8..4e187e479 100644 --- a/api/tests/federation/test_library.py +++ b/api/tests/federation/test_library.py @@ -1,70 +1,64 @@ -from funkwhale_api.federation import library -from funkwhale_api.federation import serializers +from funkwhale_api.federation import library, serializers def test_library_scan_from_account_name(mocker, factories): - actor = factories['federation.Actor']( - preferred_username='library', - domain='test.library' + actor = factories["federation.Actor"]( + preferred_username="library", domain="test.library" ) - get_resource_result = {'actor_url': actor.url} + get_resource_result = {"actor_url": actor.url} get_resource = mocker.patch( - 'funkwhale_api.federation.webfinger.get_resource', - return_value=get_resource_result) + "funkwhale_api.federation.webfinger.get_resource", + return_value=get_resource_result, + ) actor_data = serializers.ActorSerializer(actor).data - actor_data['manuallyApprovesFollowers'] = False - actor_data['url'] = [{ - 'type': 'Link', - 'name': 'library', - 'mediaType': 'application/activity+json', - 'href': 'https://test.library' - }] + actor_data["manuallyApprovesFollowers"] = False + actor_data["url"] = [ + { + "type": "Link", + "name": "library", + "mediaType": "application/activity+json", + "href": "https://test.library", + } + ] get_actor_data = mocker.patch( - 'funkwhale_api.federation.actors.get_actor_data', - return_value=actor_data) + "funkwhale_api.federation.actors.get_actor_data", return_value=actor_data + ) - get_library_data_result = {'test': 'test'} + get_library_data_result = {"test": "test"} get_library_data = mocker.patch( - 'funkwhale_api.federation.library.get_library_data', - return_value=get_library_data_result) + "funkwhale_api.federation.library.get_library_data", + return_value=get_library_data_result, + ) - result = library.scan_from_account_name('library@test.actor') + result = library.scan_from_account_name("library@test.actor") - get_resource.assert_called_once_with('acct:library@test.actor') + get_resource.assert_called_once_with("acct:library@test.actor") get_actor_data.assert_called_once_with(actor.url) - get_library_data.assert_called_once_with(actor_data['url'][0]['href']) + get_library_data.assert_called_once_with(actor_data["url"][0]["href"]) assert result == { - 'webfinger': get_resource_result, - 'actor': actor_data, - 'library': get_library_data_result, - 'local': { - 'following': False, - 'awaiting_approval': False, - }, + "webfinger": get_resource_result, + "actor": actor_data, + "library": get_library_data_result, + "local": {"following": False, "awaiting_approval": False}, } def test_get_library_data(r_mock, factories): - actor = factories['federation.Actor']() - url = 'https://test.library' - conf = { - 'id': url, - 'items': [], - 'actor': actor, - 'page_size': 5, - } + actor = factories["federation.Actor"]() + url = "https://test.library" + conf = {"id": url, "items": [], "actor": actor, "page_size": 5} data = serializers.PaginatedCollectionSerializer(conf).data r_mock.get(url, json=data) result = library.get_library_data(url) - for f in ['totalItems', 'actor', 'id', 'type']: + for f in ["totalItems", "actor", "id", "type"]: assert result[f] == data[f] def test_get_library_data_requires_authentication(r_mock, factories): - url = 'https://test.library' + url = "https://test.library" r_mock.get(url, status_code=403) result = library.get_library_data(url) - assert result['errors'] == ['Permission denied while scanning library'] + assert result["errors"] == ["Permission denied while scanning library"] diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index ae158e659..61d0aea96 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -1,41 +1,31 @@ import pytest -import uuid - from django import db -from funkwhale_api.federation import models -from funkwhale_api.federation import serializers - def test_cannot_duplicate_actor(factories): - actor = factories['federation.Actor']() + actor = factories["federation.Actor"]() with pytest.raises(db.IntegrityError): - factories['federation.Actor']( - domain=actor.domain, - preferred_username=actor.preferred_username, + factories["federation.Actor"]( + domain=actor.domain, preferred_username=actor.preferred_username ) def test_cannot_duplicate_follow(factories): - follow = factories['federation.Follow']() + follow = factories["federation.Follow"]() with pytest.raises(db.IntegrityError): - factories['federation.Follow']( - target=follow.target, - actor=follow.actor, - ) + factories["federation.Follow"](target=follow.target, actor=follow.actor) def test_follow_federation_url(factories): - follow = factories['federation.Follow'](local=True) - expected = '{}#follows/{}'.format( - follow.actor.url, follow.uuid) + follow = factories["federation.Follow"](local=True) + expected = "{}#follows/{}".format(follow.actor.url, follow.uuid) assert follow.get_federation_url() == expected def test_library_model_unique_per_actor(factories): - library = factories['federation.Library']() + library = factories["federation.Library"]() with pytest.raises(db.IntegrityError): - factories['federation.Library'](actor=library.actor) + factories["federation.Library"](actor=library.actor) diff --git a/api/tests/federation/test_permissions.py b/api/tests/federation/test_permissions.py index a87f26f1b..75f76077c 100644 --- a/api/tests/federation/test_permissions.py +++ b/api/tests/federation/test_permissions.py @@ -1,60 +1,61 @@ from rest_framework.views import APIView -from funkwhale_api.federation import actors -from funkwhale_api.federation import permissions +from funkwhale_api.federation import actors, permissions -def test_library_follower( - factories, api_request, anonymous_user, preferences): - preferences['federation__music_needs_approval'] = True +def test_library_follower(factories, api_request, anonymous_user, preferences): + preferences["federation__music_needs_approval"] = True view = APIView.as_view() permission = permissions.LibraryFollower() - request = api_request.get('/') - setattr(request, 'user', anonymous_user) + request = api_request.get("/") + setattr(request, "user", anonymous_user) check = permission.has_permission(request, view) assert check is False def test_library_follower_actor_non_follower( - factories, api_request, anonymous_user, preferences): - preferences['federation__music_needs_approval'] = True - actor = factories['federation.Actor']() + factories, api_request, anonymous_user, preferences +): + preferences["federation__music_needs_approval"] = True + actor = factories["federation.Actor"]() view = APIView.as_view() permission = permissions.LibraryFollower() - request = api_request.get('/') - setattr(request, 'user', anonymous_user) - setattr(request, 'actor', actor) + request = api_request.get("/") + setattr(request, "user", anonymous_user) + setattr(request, "actor", actor) check = permission.has_permission(request, view) assert check is False def test_library_follower_actor_follower_not_approved( - factories, api_request, anonymous_user, preferences): - preferences['federation__music_needs_approval'] = True - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow = factories['federation.Follow'](target=library, approved=False) + factories, api_request, anonymous_user, preferences +): + preferences["federation__music_needs_approval"] = True + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() + follow = factories["federation.Follow"](target=library, approved=False) view = APIView.as_view() permission = permissions.LibraryFollower() - request = api_request.get('/') - setattr(request, 'user', anonymous_user) - setattr(request, 'actor', follow.actor) + request = api_request.get("/") + setattr(request, "user", anonymous_user) + setattr(request, "actor", follow.actor) check = permission.has_permission(request, view) assert check is False def test_library_follower_actor_follower( - factories, api_request, anonymous_user, preferences): - preferences['federation__music_needs_approval'] = True - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow = factories['federation.Follow'](target=library, approved=True) + factories, api_request, anonymous_user, preferences +): + preferences["federation__music_needs_approval"] = True + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() + follow = factories["federation.Follow"](target=library, approved=True) view = APIView.as_view() permission = permissions.LibraryFollower() - request = api_request.get('/') - setattr(request, 'user', anonymous_user) - setattr(request, 'actor', follow.actor) + request = api_request.get("/") + setattr(request, "user", anonymous_user) + setattr(request, "actor", follow.actor) check = permission.has_permission(request, view) assert check is True diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index fcf2ba1b6..e966d1711 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -1,37 +1,29 @@ import arrow import pytest - -from django.urls import reverse from django.core.paginator import Paginator -from funkwhale_api.federation import actors -from funkwhale_api.federation import keys -from funkwhale_api.federation import models -from funkwhale_api.federation import serializers -from funkwhale_api.federation import utils +from funkwhale_api.federation import actors, models, serializers, utils def test_actor_serializer_from_ap(db): payload = { - 'id': 'https://test.federation/user', - 'type': 'Person', - 'following': 'https://test.federation/user/following', - 'followers': 'https://test.federation/user/followers', - 'inbox': 'https://test.federation/user/inbox', - 'outbox': 'https://test.federation/user/outbox', - 'preferredUsername': 'user', - 'name': 'Real User', - 'summary': 'Hello world', - 'url': 'https://test.federation/@user', - 'manuallyApprovesFollowers': False, - 'publicKey': { - 'id': 'https://test.federation/user#main-key', - 'owner': 'https://test.federation/user', - 'publicKeyPem': 'yolo' - }, - 'endpoints': { - 'sharedInbox': 'https://test.federation/inbox' + "id": "https://test.federation/user", + "type": "Person", + "following": "https://test.federation/user/following", + "followers": "https://test.federation/user/followers", + "inbox": "https://test.federation/user/inbox", + "outbox": "https://test.federation/user/outbox", + "preferredUsername": "user", + "name": "Real User", + "summary": "Hello world", + "url": "https://test.federation/@user", + "manuallyApprovesFollowers": False, + "publicKey": { + "id": "https://test.federation/user#main-key", + "owner": "https://test.federation/user", + "publicKeyPem": "yolo", }, + "endpoints": {"sharedInbox": "https://test.federation/inbox"}, } serializer = serializers.ActorSerializer(data=payload) @@ -39,30 +31,30 @@ def test_actor_serializer_from_ap(db): actor = serializer.build() - assert actor.url == payload['id'] - assert actor.inbox_url == payload['inbox'] - assert actor.outbox_url == payload['outbox'] - assert actor.shared_inbox_url == payload['endpoints']['sharedInbox'] - assert actor.followers_url == payload['followers'] - assert actor.following_url == payload['following'] - assert actor.public_key == payload['publicKey']['publicKeyPem'] - assert actor.preferred_username == payload['preferredUsername'] - assert actor.name == payload['name'] - assert actor.domain == 'test.federation' - assert actor.summary == payload['summary'] - assert actor.type == 'Person' - assert actor.manually_approves_followers == payload['manuallyApprovesFollowers'] + assert actor.url == payload["id"] + assert actor.inbox_url == payload["inbox"] + assert actor.outbox_url == payload["outbox"] + assert actor.shared_inbox_url == payload["endpoints"]["sharedInbox"] + assert actor.followers_url == payload["followers"] + assert actor.following_url == payload["following"] + assert actor.public_key == payload["publicKey"]["publicKeyPem"] + assert actor.preferred_username == payload["preferredUsername"] + assert actor.name == payload["name"] + assert actor.domain == "test.federation" + assert actor.summary == payload["summary"] + assert actor.type == "Person" + assert actor.manually_approves_followers == payload["manuallyApprovesFollowers"] def test_actor_serializer_only_mandatory_field_from_ap(db): payload = { - 'id': 'https://test.federation/user', - 'type': 'Person', - 'following': 'https://test.federation/user/following', - 'followers': 'https://test.federation/user/followers', - 'inbox': 'https://test.federation/user/inbox', - 'outbox': 'https://test.federation/user/outbox', - 'preferredUsername': 'user', + "id": "https://test.federation/user", + "type": "Person", + "following": "https://test.federation/user/following", + "followers": "https://test.federation/user/followers", + "inbox": "https://test.federation/user/inbox", + "outbox": "https://test.federation/user/outbox", + "preferredUsername": "user", } serializer = serializers.ActorSerializer(data=payload) @@ -70,58 +62,55 @@ def test_actor_serializer_only_mandatory_field_from_ap(db): actor = serializer.build() - assert actor.url == payload['id'] - assert actor.inbox_url == payload['inbox'] - assert actor.outbox_url == payload['outbox'] - assert actor.followers_url == payload['followers'] - assert actor.following_url == payload['following'] - assert actor.preferred_username == payload['preferredUsername'] - assert actor.domain == 'test.federation' - assert actor.type == 'Person' + assert actor.url == payload["id"] + assert actor.inbox_url == payload["inbox"] + assert actor.outbox_url == payload["outbox"] + assert actor.followers_url == payload["followers"] + assert actor.following_url == payload["following"] + assert actor.preferred_username == payload["preferredUsername"] + assert actor.domain == "test.federation" + assert actor.type == "Person" assert actor.manually_approves_followers is None def test_actor_serializer_to_ap(): expected = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'id': 'https://test.federation/user', - 'type': 'Person', - 'following': 'https://test.federation/user/following', - 'followers': 'https://test.federation/user/followers', - 'inbox': 'https://test.federation/user/inbox', - 'outbox': 'https://test.federation/user/outbox', - 'preferredUsername': 'user', - 'name': 'Real User', - 'summary': 'Hello world', - 'manuallyApprovesFollowers': False, - 'publicKey': { - 'id': 'https://test.federation/user#main-key', - 'owner': 'https://test.federation/user', - 'publicKeyPem': 'yolo' - }, - 'endpoints': { - 'sharedInbox': 'https://test.federation/inbox' + "id": "https://test.federation/user", + "type": "Person", + "following": "https://test.federation/user/following", + "followers": "https://test.federation/user/followers", + "inbox": "https://test.federation/user/inbox", + "outbox": "https://test.federation/user/outbox", + "preferredUsername": "user", + "name": "Real User", + "summary": "Hello world", + "manuallyApprovesFollowers": False, + "publicKey": { + "id": "https://test.federation/user#main-key", + "owner": "https://test.federation/user", + "publicKeyPem": "yolo", }, + "endpoints": {"sharedInbox": "https://test.federation/inbox"}, } ac = models.Actor( - url=expected['id'], - inbox_url=expected['inbox'], - outbox_url=expected['outbox'], - shared_inbox_url=expected['endpoints']['sharedInbox'], - followers_url=expected['followers'], - following_url=expected['following'], - public_key=expected['publicKey']['publicKeyPem'], - preferred_username=expected['preferredUsername'], - name=expected['name'], - domain='test.federation', - summary=expected['summary'], - type='Person', + url=expected["id"], + inbox_url=expected["inbox"], + outbox_url=expected["outbox"], + shared_inbox_url=expected["endpoints"]["sharedInbox"], + followers_url=expected["followers"], + following_url=expected["following"], + public_key=expected["publicKey"]["publicKeyPem"], + preferred_username=expected["preferredUsername"], + name=expected["name"], + domain="test.federation", + summary=expected["summary"], + type="Person", manually_approves_followers=False, - ) serializer = serializers.ActorSerializer(ac) @@ -130,22 +119,20 @@ def test_actor_serializer_to_ap(): def test_webfinger_serializer(): expected = { - 'subject': 'acct:service@test.federation', - 'links': [ + "subject": "acct:service@test.federation", + "links": [ { - 'rel': 'self', - 'href': 'https://test.federation/federation/instance/actor', - 'type': 'application/activity+json', + "rel": "self", + "href": "https://test.federation/federation/instance/actor", + "type": "application/activity+json", } ], - 'aliases': [ - 'https://test.federation/federation/instance/actor', - ] + "aliases": ["https://test.federation/federation/instance/actor"], } actor = models.Actor( - url=expected['links'][0]['href'], - preferred_username='service', - domain='test.federation', + url=expected["links"][0]["href"], + preferred_username="service", + domain="test.federation", ) serializer = serializers.ActorWebfingerSerializer(actor) @@ -153,33 +140,33 @@ def test_webfinger_serializer(): def test_follow_serializer_to_ap(factories): - follow = factories['federation.Follow'](local=True) + follow = factories["federation.Follow"](local=True) serializer = serializers.FollowSerializer(follow) expected = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'id': follow.get_federation_url(), - 'type': 'Follow', - 'actor': follow.actor.url, - 'object': follow.target.url, + "id": follow.get_federation_url(), + "type": "Follow", + "actor": follow.actor.url, + "object": follow.target.url, } assert serializer.data == expected def test_follow_serializer_save(factories): - actor = factories['federation.Actor']() - target = factories['federation.Actor']() + actor = factories["federation.Actor"]() + target = factories["federation.Actor"]() - data = expected = { - 'id': 'https://test.follow', - 'type': 'Follow', - 'actor': actor.url, - 'object': target.url, + data = { + "id": "https://test.follow", + "type": "Follow", + "actor": actor.url, + "object": target.url, } serializer = serializers.FollowSerializer(data=data) @@ -194,39 +181,39 @@ def test_follow_serializer_save(factories): def test_follow_serializer_save_validates_on_context(factories): - actor = factories['federation.Actor']() - target = factories['federation.Actor']() - impostor = factories['federation.Actor']() + actor = factories["federation.Actor"]() + target = factories["federation.Actor"]() + impostor = factories["federation.Actor"]() - data = expected = { - 'id': 'https://test.follow', - 'type': 'Follow', - 'actor': actor.url, - 'object': target.url, + data = { + "id": "https://test.follow", + "type": "Follow", + "actor": actor.url, + "object": target.url, } serializer = serializers.FollowSerializer( - data=data, - context={'follow_actor': impostor, 'follow_target': impostor}) + data=data, context={"follow_actor": impostor, "follow_target": impostor} + ) assert serializer.is_valid() is False - assert 'actor' in serializer.errors - assert 'object' in serializer.errors + assert "actor" in serializer.errors + assert "object" in serializer.errors def test_accept_follow_serializer_representation(factories): - follow = factories['federation.Follow'](approved=None) + follow = factories["federation.Follow"](approved=None) expected = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'id': follow.get_federation_url() + '/accept', - 'type': 'Accept', - 'actor': follow.target.url, - 'object': serializers.FollowSerializer(follow).data, + "id": follow.get_federation_url() + "/accept", + "type": "Accept", + "actor": follow.target.url, + "object": serializers.FollowSerializer(follow).data, } serializer = serializers.AcceptFollowSerializer(follow) @@ -235,18 +222,18 @@ def test_accept_follow_serializer_representation(factories): def test_accept_follow_serializer_save(factories): - follow = factories['federation.Follow'](approved=None) + follow = factories["federation.Follow"](approved=None) data = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'id': follow.get_federation_url() + '/accept', - 'type': 'Accept', - 'actor': follow.target.url, - 'object': serializers.FollowSerializer(follow).data, + "id": follow.get_federation_url() + "/accept", + "type": "Accept", + "actor": follow.target.url, + "object": serializers.FollowSerializer(follow).data, } serializer = serializers.AcceptFollowSerializer(data=data) @@ -259,42 +246,42 @@ def test_accept_follow_serializer_save(factories): def test_accept_follow_serializer_validates_on_context(factories): - follow = factories['federation.Follow'](approved=None) - impostor = factories['federation.Actor']() + follow = factories["federation.Follow"](approved=None) + impostor = factories["federation.Actor"]() data = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'id': follow.get_federation_url() + '/accept', - 'type': 'Accept', - 'actor': impostor.url, - 'object': serializers.FollowSerializer(follow).data, + "id": follow.get_federation_url() + "/accept", + "type": "Accept", + "actor": impostor.url, + "object": serializers.FollowSerializer(follow).data, } serializer = serializers.AcceptFollowSerializer( - data=data, - context={'follow_actor': impostor, 'follow_target': impostor}) + data=data, context={"follow_actor": impostor, "follow_target": impostor} + ) assert serializer.is_valid() is False - assert 'actor' in serializer.errors['object'] - assert 'object' in serializer.errors['object'] + assert "actor" in serializer.errors["object"] + assert "object" in serializer.errors["object"] def test_undo_follow_serializer_representation(factories): - follow = factories['federation.Follow'](approved=True) + follow = factories["federation.Follow"](approved=True) expected = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'id': follow.get_federation_url() + '/undo', - 'type': 'Undo', - 'actor': follow.actor.url, - 'object': serializers.FollowSerializer(follow).data, + "id": follow.get_federation_url() + "/undo", + "type": "Undo", + "actor": follow.actor.url, + "object": serializers.FollowSerializer(follow).data, } serializer = serializers.UndoFollowSerializer(follow) @@ -303,18 +290,18 @@ def test_undo_follow_serializer_representation(factories): def test_undo_follow_serializer_save(factories): - follow = factories['federation.Follow'](approved=True) + follow = factories["federation.Follow"](approved=True) data = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'id': follow.get_federation_url() + '/undo', - 'type': 'Undo', - 'actor': follow.actor.url, - 'object': serializers.FollowSerializer(follow).data, + "id": follow.get_federation_url() + "/undo", + "type": "Undo", + "actor": follow.actor.url, + "object": serializers.FollowSerializer(follow).data, } serializer = serializers.UndoFollowSerializer(data=data) @@ -326,53 +313,53 @@ def test_undo_follow_serializer_save(factories): def test_undo_follow_serializer_validates_on_context(factories): - follow = factories['federation.Follow'](approved=True) - impostor = factories['federation.Actor']() + follow = factories["federation.Follow"](approved=True) + impostor = factories["federation.Actor"]() data = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'id': follow.get_federation_url() + '/undo', - 'type': 'Undo', - 'actor': impostor.url, - 'object': serializers.FollowSerializer(follow).data, + "id": follow.get_federation_url() + "/undo", + "type": "Undo", + "actor": impostor.url, + "object": serializers.FollowSerializer(follow).data, } serializer = serializers.UndoFollowSerializer( - data=data, - context={'follow_actor': impostor, 'follow_target': impostor}) + data=data, context={"follow_actor": impostor, "follow_target": impostor} + ) assert serializer.is_valid() is False - assert 'actor' in serializer.errors['object'] - assert 'object' in serializer.errors['object'] + assert "actor" in serializer.errors["object"] + assert "object" in serializer.errors["object"] def test_paginated_collection_serializer(factories): - tfs = factories['music.TrackFile'].create_batch(size=5) - actor = factories['federation.Actor'](local=True) + tfs = factories["music.TrackFile"].create_batch(size=5) + actor = factories["federation.Actor"](local=True) conf = { - 'id': 'https://test.federation/test', - 'items': tfs, - 'item_serializer': serializers.AudioSerializer, - 'actor': actor, - 'page_size': 2, + "id": "https://test.federation/test", + "items": tfs, + "item_serializer": serializers.AudioSerializer, + "actor": actor, + "page_size": 2, } expected = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'type': 'Collection', - 'id': conf['id'], - 'actor': actor.url, - 'totalItems': len(tfs), - 'current': conf['id'] + '?page=1', - 'last': conf['id'] + '?page=3', - 'first': conf['id'] + '?page=1', + "type": "Collection", + "id": conf["id"], + "actor": actor.url, + "totalItems": len(tfs), + "current": conf["id"] + "?page=1", + "last": conf["id"] + "?page=3", + "first": conf["id"] + "?page=1", } serializer = serializers.PaginatedCollectionSerializer(conf) @@ -382,108 +369,102 @@ def test_paginated_collection_serializer(factories): def test_paginated_collection_serializer_validation(): data = { - 'type': 'Collection', - 'id': 'https://test.federation/test', - 'totalItems': 5, - 'actor': 'http://test.actor', - 'first': 'https://test.federation/test?page=1', - 'last': 'https://test.federation/test?page=1', - 'items': [] + "type": "Collection", + "id": "https://test.federation/test", + "totalItems": 5, + "actor": "http://test.actor", + "first": "https://test.federation/test?page=1", + "last": "https://test.federation/test?page=1", + "items": [], } - serializer = serializers.PaginatedCollectionSerializer( - data=data - ) + serializer = serializers.PaginatedCollectionSerializer(data=data) assert serializer.is_valid(raise_exception=True) is True - assert serializer.validated_data['totalItems'] == 5 - assert serializer.validated_data['id'] == data['id'] - assert serializer.validated_data['actor'] == data['actor'] + assert serializer.validated_data["totalItems"] == 5 + assert serializer.validated_data["id"] == data["id"] + assert serializer.validated_data["actor"] == data["actor"] def test_collection_page_serializer_validation(): - base = 'https://test.federation/test' + base = "https://test.federation/test" data = { - 'type': 'CollectionPage', - 'id': base + '?page=2', - 'totalItems': 5, - 'actor': 'https://test.actor', - 'items': [], - 'first': 'https://test.federation/test?page=1', - 'last': 'https://test.federation/test?page=3', - 'prev': base + '?page=1', - 'next': base + '?page=3', - 'partOf': base, + "type": "CollectionPage", + "id": base + "?page=2", + "totalItems": 5, + "actor": "https://test.actor", + "items": [], + "first": "https://test.federation/test?page=1", + "last": "https://test.federation/test?page=3", + "prev": base + "?page=1", + "next": base + "?page=3", + "partOf": base, } - serializer = serializers.CollectionPageSerializer( - data=data - ) + serializer = serializers.CollectionPageSerializer(data=data) assert serializer.is_valid(raise_exception=True) is True - assert serializer.validated_data['totalItems'] == 5 - assert serializer.validated_data['id'] == data['id'] - assert serializer.validated_data['actor'] == data['actor'] - assert serializer.validated_data['items'] == [] - assert serializer.validated_data['prev'] == data['prev'] - assert serializer.validated_data['next'] == data['next'] - assert serializer.validated_data['partOf'] == data['partOf'] + assert serializer.validated_data["totalItems"] == 5 + assert serializer.validated_data["id"] == data["id"] + assert serializer.validated_data["actor"] == data["actor"] + assert serializer.validated_data["items"] == [] + assert serializer.validated_data["prev"] == data["prev"] + assert serializer.validated_data["next"] == data["next"] + assert serializer.validated_data["partOf"] == data["partOf"] def test_collection_page_serializer_can_validate_child(): data = { - 'type': 'CollectionPage', - 'id': 'https://test.page?page=2', - 'actor': 'https://test.actor', - 'first': 'https://test.page?page=1', - 'last': 'https://test.page?page=3', - 'partOf': 'https://test.page', - 'totalItems': 1, - 'items': [{'in': 'valid'}], + "type": "CollectionPage", + "id": "https://test.page?page=2", + "actor": "https://test.actor", + "first": "https://test.page?page=1", + "last": "https://test.page?page=3", + "partOf": "https://test.page", + "totalItems": 1, + "items": [{"in": "valid"}], } serializer = serializers.CollectionPageSerializer( - data=data, - context={'item_serializer': serializers.AudioSerializer} + data=data, context={"item_serializer": serializers.AudioSerializer} ) # child are validated but not included in data if not valid assert serializer.is_valid(raise_exception=True) is True - assert len(serializer.validated_data['items']) == 0 + assert len(serializer.validated_data["items"]) == 0 def test_collection_page_serializer(factories): - tfs = factories['music.TrackFile'].create_batch(size=5) - actor = factories['federation.Actor'](local=True) + tfs = factories["music.TrackFile"].create_batch(size=5) + actor = factories["federation.Actor"](local=True) conf = { - 'id': 'https://test.federation/test', - 'item_serializer': serializers.AudioSerializer, - 'actor': actor, - 'page': Paginator(tfs, 2).page(2), + "id": "https://test.federation/test", + "item_serializer": serializers.AudioSerializer, + "actor": actor, + "page": Paginator(tfs, 2).page(2), } expected = { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", {}, ], - 'type': 'CollectionPage', - 'id': conf['id'] + '?page=2', - 'actor': actor.url, - 'totalItems': len(tfs), - 'partOf': conf['id'], - 'prev': conf['id'] + '?page=1', - 'next': conf['id'] + '?page=3', - 'first': conf['id'] + '?page=1', - 'last': conf['id'] + '?page=3', - 'items': [ - conf['item_serializer']( - i, - context={'actor': actor, 'include_ap_context': False} + "type": "CollectionPage", + "id": conf["id"] + "?page=2", + "actor": actor.url, + "totalItems": len(tfs), + "partOf": conf["id"], + "prev": conf["id"] + "?page=1", + "next": conf["id"] + "?page=3", + "first": conf["id"] + "?page=1", + "last": conf["id"] + "?page=3", + "items": [ + conf["item_serializer"]( + i, context={"actor": actor, "include_ap_context": False} ).data - for i in conf['page'].object_list - ] + for i in conf["page"].object_list + ], } serializer = serializers.CollectionPageSerializer(conf) @@ -492,35 +473,37 @@ def test_collection_page_serializer(factories): def test_activity_pub_audio_serializer_to_library_track(factories): - remote_library = factories['federation.Library']() - audio = factories['federation.Audio']() + remote_library = factories["federation.Library"]() + audio = factories["federation.Audio"]() serializer = serializers.AudioSerializer( - data=audio, context={'library': remote_library}) + data=audio, context={"library": remote_library} + ) assert serializer.is_valid(raise_exception=True) lt = serializer.save() assert lt.pk is not None - assert lt.url == audio['id'] + assert lt.url == audio["id"] assert lt.library == remote_library - assert lt.audio_url == audio['url']['href'] - assert lt.audio_mimetype == audio['url']['mediaType'] - assert lt.metadata == audio['metadata'] - assert lt.title == audio['metadata']['recording']['title'] - assert lt.artist_name == audio['metadata']['artist']['name'] - assert lt.album_title == audio['metadata']['release']['title'] - assert lt.published_date == arrow.get(audio['published']) + assert lt.audio_url == audio["url"]["href"] + assert lt.audio_mimetype == audio["url"]["mediaType"] + assert lt.metadata == audio["metadata"] + assert lt.title == audio["metadata"]["recording"]["title"] + assert lt.artist_name == audio["metadata"]["artist"]["name"] + assert lt.album_title == audio["metadata"]["release"]["title"] + assert lt.published_date == arrow.get(audio["published"]) -def test_activity_pub_audio_serializer_to_library_track_no_duplicate( - factories): - remote_library = factories['federation.Library']() - audio = factories['federation.Audio']() +def test_activity_pub_audio_serializer_to_library_track_no_duplicate(factories): + remote_library = factories["federation.Library"]() + audio = factories["federation.Audio"]() serializer1 = serializers.AudioSerializer( - data=audio, context={'library': remote_library}) + data=audio, context={"library": remote_library} + ) serializer2 = serializers.AudioSerializer( - data=audio, context={'library': remote_library}) + data=audio, context={"library": remote_library} + ) assert serializer1.is_valid() is True assert serializer2.is_valid() is True @@ -533,192 +516,168 @@ def test_activity_pub_audio_serializer_to_library_track_no_duplicate( def test_activity_pub_audio_serializer_to_ap(factories): - tf = factories['music.TrackFile']( - mimetype='audio/mp3', - bitrate=42, - duration=43, - size=44, + tf = factories["music.TrackFile"]( + mimetype="audio/mp3", bitrate=42, duration=43, size=44 ) - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() expected = { - '@context': serializers.AP_CONTEXT, - 'type': 'Audio', - 'id': tf.get_federation_url(), - 'name': tf.track.full_name, - 'published': tf.creation_date.isoformat(), - 'updated': tf.modification_date.isoformat(), - 'metadata': { - 'artist': { - 'musicbrainz_id': tf.track.artist.mbid, - 'name': tf.track.artist.name, + "@context": serializers.AP_CONTEXT, + "type": "Audio", + "id": tf.get_federation_url(), + "name": tf.track.full_name, + "published": tf.creation_date.isoformat(), + "updated": tf.modification_date.isoformat(), + "metadata": { + "artist": { + "musicbrainz_id": tf.track.artist.mbid, + "name": tf.track.artist.name, }, - 'release': { - 'musicbrainz_id': tf.track.album.mbid, - 'title': tf.track.album.title, + "release": { + "musicbrainz_id": tf.track.album.mbid, + "title": tf.track.album.title, }, - 'recording': { - 'musicbrainz_id': tf.track.mbid, - 'title': tf.track.title, - }, - 'size': tf.size, - 'length': tf.duration, - 'bitrate': tf.bitrate, + "recording": {"musicbrainz_id": tf.track.mbid, "title": tf.track.title}, + "size": tf.size, + "length": tf.duration, + "bitrate": tf.bitrate, }, - 'url': { - 'href': utils.full_url(tf.path), - 'type': 'Link', - 'mediaType': 'audio/mp3' + "url": { + "href": utils.full_url(tf.path), + "type": "Link", + "mediaType": "audio/mp3", }, - 'attributedTo': [ - library.url - ] + "attributedTo": [library.url], } - serializer = serializers.AudioSerializer(tf, context={'actor': library}) + serializer = serializers.AudioSerializer(tf, context={"actor": library}) assert serializer.data == expected def test_activity_pub_audio_serializer_to_ap_no_mbid(factories): - tf = factories['music.TrackFile']( - mimetype='audio/mp3', + tf = factories["music.TrackFile"]( + mimetype="audio/mp3", track__mbid=None, track__album__mbid=None, track__album__artist__mbid=None, ) - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() expected = { - '@context': serializers.AP_CONTEXT, - 'type': 'Audio', - 'id': tf.get_federation_url(), - 'name': tf.track.full_name, - 'published': tf.creation_date.isoformat(), - 'updated': tf.modification_date.isoformat(), - 'metadata': { - 'artist': { - 'name': tf.track.artist.name, - 'musicbrainz_id': None, - }, - 'release': { - 'title': tf.track.album.title, - 'musicbrainz_id': None, - }, - 'recording': { - 'title': tf.track.title, - 'musicbrainz_id': None, - }, - 'size': None, - 'length': None, - 'bitrate': None, + "@context": serializers.AP_CONTEXT, + "type": "Audio", + "id": tf.get_federation_url(), + "name": tf.track.full_name, + "published": tf.creation_date.isoformat(), + "updated": tf.modification_date.isoformat(), + "metadata": { + "artist": {"name": tf.track.artist.name, "musicbrainz_id": None}, + "release": {"title": tf.track.album.title, "musicbrainz_id": None}, + "recording": {"title": tf.track.title, "musicbrainz_id": None}, + "size": None, + "length": None, + "bitrate": None, }, - 'url': { - 'href': utils.full_url(tf.path), - 'type': 'Link', - 'mediaType': 'audio/mp3' + "url": { + "href": utils.full_url(tf.path), + "type": "Link", + "mediaType": "audio/mp3", }, - 'attributedTo': [ - library.url - ] + "attributedTo": [library.url], } - serializer = serializers.AudioSerializer(tf, context={'actor': library}) + serializer = serializers.AudioSerializer(tf, context={"actor": library}) assert serializer.data == expected def test_collection_serializer_to_ap(factories): - tf1 = factories['music.TrackFile'](mimetype='audio/mp3') - tf2 = factories['music.TrackFile'](mimetype='audio/ogg') - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() + tf1 = factories["music.TrackFile"](mimetype="audio/mp3") + tf2 = factories["music.TrackFile"](mimetype="audio/ogg") + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() expected = { - '@context': serializers.AP_CONTEXT, - 'id': 'https://test.id', - 'actor': library.url, - 'totalItems': 2, - 'type': 'Collection', - 'items': [ + "@context": serializers.AP_CONTEXT, + "id": "https://test.id", + "actor": library.url, + "totalItems": 2, + "type": "Collection", + "items": [ serializers.AudioSerializer( - tf1, context={'actor': library, 'include_ap_context': False} + tf1, context={"actor": library, "include_ap_context": False} ).data, serializers.AudioSerializer( - tf2, context={'actor': library, 'include_ap_context': False} + tf2, context={"actor": library, "include_ap_context": False} ).data, - ] + ], } collection = { - 'id': expected['id'], - 'actor': library, - 'items': [tf1, tf2], - 'item_serializer': serializers.AudioSerializer + "id": expected["id"], + "actor": library, + "items": [tf1, tf2], + "item_serializer": serializers.AudioSerializer, } serializer = serializers.CollectionSerializer( - collection, context={'actor': library, 'id': 'https://test.id'}) + collection, context={"actor": library, "id": "https://test.id"} + ) assert serializer.data == expected def test_api_library_create_serializer_save(factories, r_mock): - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - actor = factories['federation.Actor']() - follow = factories['federation.Follow']( - target=actor, - actor=library_actor, - ) + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + actor = factories["federation.Actor"]() + follow = factories["federation.Follow"](target=actor, actor=library_actor) actor_data = serializers.ActorSerializer(actor).data - actor_data['url'] = [{ - 'href': 'https://test.library', - 'name': 'library', - 'type': 'Link', - }] + actor_data["url"] = [ + {"href": "https://test.library", "name": "library", "type": "Link"} + ] library_conf = { - 'id': 'https://test.library', - 'items': range(10), - 'actor': actor, - 'page_size': 5, + "id": "https://test.library", + "items": range(10), + "actor": actor, + "page_size": 5, } library_data = serializers.PaginatedCollectionSerializer(library_conf).data r_mock.get(actor.url, json=actor_data) - r_mock.get('https://test.library', json=library_data) + r_mock.get("https://test.library", json=library_data) data = { - 'actor': actor.url, - 'autoimport': False, - 'federation_enabled': True, - 'download_files': False, + "actor": actor.url, + "autoimport": False, + "federation_enabled": True, + "download_files": False, } serializer = serializers.APILibraryCreateSerializer(data=data) assert serializer.is_valid(raise_exception=True) is True library = serializer.save() - follow = models.Follow.objects.get( - target=actor, actor=library_actor, approved=None) + follow = models.Follow.objects.get(target=actor, actor=library_actor, approved=None) - assert library.autoimport is data['autoimport'] - assert library.federation_enabled is data['federation_enabled'] - assert library.download_files is data['download_files'] + assert library.autoimport is data["autoimport"] + assert library.federation_enabled is data["federation_enabled"] + assert library.download_files is data["download_files"] assert library.tracks_count == 10 assert library.actor == actor assert library.follow == follow def test_tapi_library_track_serializer_not_imported(factories): - lt = factories['federation.LibraryTrack']() + lt = factories["federation.LibraryTrack"]() serializer = serializers.APILibraryTrackSerializer(lt) - assert serializer.get_status(lt) == 'not_imported' + assert serializer.get_status(lt) == "not_imported" def test_tapi_library_track_serializer_imported(factories): - tf = factories['music.TrackFile'](federation=True) + tf = factories["music.TrackFile"](federation=True) lt = tf.library_track serializer = serializers.APILibraryTrackSerializer(lt) - assert serializer.get_status(lt) == 'imported' + assert serializer.get_status(lt) == "imported" def test_tapi_library_track_serializer_import_pending(factories): - job = factories['music.ImportJob'](federation=True, status='pending') + job = factories["music.ImportJob"](federation=True, status="pending") lt = job.library_track serializer = serializers.APILibraryTrackSerializer(lt) - assert serializer.get_status(lt) == 'import_pending' + assert serializer.get_status(lt) == "import_pending" diff --git a/api/tests/federation/test_signing.py b/api/tests/federation/test_signing.py index 0c1ec2e0b..159f31cd9 100644 --- a/api/tests/federation/test_signing.py +++ b/api/tests/federation/test_signing.py @@ -1,43 +1,35 @@ import cryptography.exceptions -import io import pytest -import requests_http_signature -from funkwhale_api.federation import signing -from funkwhale_api.federation import keys +from funkwhale_api.federation import keys, signing def test_can_sign_and_verify_request(nodb_factories): - private, public = nodb_factories['federation.KeyPair']() - auth = nodb_factories['federation.SignatureAuth'](key=private) - request = nodb_factories['federation.SignedRequest']( - auth=auth - ) + private, public = nodb_factories["federation.KeyPair"]() + auth = nodb_factories["federation.SignatureAuth"](key=private) + request = nodb_factories["federation.SignedRequest"](auth=auth) prepared_request = request.prepare() - assert 'date' in prepared_request.headers - assert 'signature' in prepared_request.headers - assert signing.verify( - prepared_request, public) is None + assert "date" in prepared_request.headers + assert "signature" in prepared_request.headers + assert signing.verify(prepared_request, public) is None def test_can_sign_and_verify_request_digest(nodb_factories): - private, public = nodb_factories['federation.KeyPair']() - auth = nodb_factories['federation.SignatureAuth'](key=private) - request = nodb_factories['federation.SignedRequest']( - auth=auth, - method='post', - data=b'hello=world' + private, public = nodb_factories["federation.KeyPair"]() + auth = nodb_factories["federation.SignatureAuth"](key=private) + request = nodb_factories["federation.SignedRequest"]( + auth=auth, method="post", data=b"hello=world" ) prepared_request = request.prepare() - assert 'date' in prepared_request.headers - assert 'digest' in prepared_request.headers - assert 'signature' in prepared_request.headers + assert "date" in prepared_request.headers + assert "digest" in prepared_request.headers + assert "signature" in prepared_request.headers assert signing.verify(prepared_request, public) is None def test_verify_fails_with_wrong_key(nodb_factories): - wrong_private, wrong_public = nodb_factories['federation.KeyPair']() - request = nodb_factories['federation.SignedRequest']() + wrong_private, wrong_public = nodb_factories["federation.KeyPair"]() + request = nodb_factories["federation.SignedRequest"]() prepared_request = request.prepare() with pytest.raises(cryptography.exceptions.InvalidSignature): @@ -46,18 +38,15 @@ def test_verify_fails_with_wrong_key(nodb_factories): def test_can_verify_django_request(factories, fake_request): private_key, public_key = keys.get_key_pair() - signed_request = factories['federation.SignedRequest']( - auth__key=private_key, - auth__headers=[ - 'date', - ] + signed_request = factories["federation.SignedRequest"]( + auth__key=private_key, auth__headers=["date"] ) prepared = signed_request.prepare() django_request = fake_request.get( - '/', + "/", **{ - 'HTTP_DATE': prepared.headers['date'], - 'HTTP_SIGNATURE': prepared.headers['signature'], + "HTTP_DATE": prepared.headers["date"], + "HTTP_SIGNATURE": prepared.headers["signature"], } ) assert signing.verify_django(django_request, public_key) is None @@ -65,22 +54,19 @@ def test_can_verify_django_request(factories, fake_request): def test_can_verify_django_request_digest(factories, fake_request): private_key, public_key = keys.get_key_pair() - signed_request = factories['federation.SignedRequest']( + signed_request = factories["federation.SignedRequest"]( auth__key=private_key, - method='post', - data=b'hello=world', - auth__headers=[ - 'date', - 'digest', - ] + method="post", + data=b"hello=world", + auth__headers=["date", "digest"], ) prepared = signed_request.prepare() django_request = fake_request.post( - '/', + "/", **{ - 'HTTP_DATE': prepared.headers['date'], - 'HTTP_DIGEST': prepared.headers['digest'], - 'HTTP_SIGNATURE': prepared.headers['signature'], + "HTTP_DATE": prepared.headers["date"], + "HTTP_DIGEST": prepared.headers["digest"], + "HTTP_SIGNATURE": prepared.headers["signature"], } ) @@ -89,22 +75,19 @@ def test_can_verify_django_request_digest(factories, fake_request): def test_can_verify_django_request_digest_failure(factories, fake_request): private_key, public_key = keys.get_key_pair() - signed_request = factories['federation.SignedRequest']( + signed_request = factories["federation.SignedRequest"]( auth__key=private_key, - method='post', - data=b'hello=world', - auth__headers=[ - 'date', - 'digest', - ] + method="post", + data=b"hello=world", + auth__headers=["date", "digest"], ) prepared = signed_request.prepare() django_request = fake_request.post( - '/', + "/", **{ - 'HTTP_DATE': prepared.headers['date'], - 'HTTP_DIGEST': prepared.headers['digest'] + 'noop', - 'HTTP_SIGNATURE': prepared.headers['signature'], + "HTTP_DATE": prepared.headers["date"], + "HTTP_DIGEST": prepared.headers["digest"] + "noop", + "HTTP_SIGNATURE": prepared.headers["signature"], } ) @@ -114,19 +97,12 @@ def test_can_verify_django_request_digest_failure(factories, fake_request): def test_can_verify_django_request_failure(factories, fake_request): private_key, public_key = keys.get_key_pair() - signed_request = factories['federation.SignedRequest']( - auth__key=private_key, - auth__headers=[ - 'date', - ] + signed_request = factories["federation.SignedRequest"]( + auth__key=private_key, auth__headers=["date"] ) prepared = signed_request.prepare() django_request = fake_request.get( - '/', - **{ - 'HTTP_DATE': 'Wrong', - 'HTTP_SIGNATURE': prepared.headers['signature'], - } + "/", **{"HTTP_DATE": "Wrong", "HTTP_SIGNATURE": prepared.headers["signature"]} ) with pytest.raises(cryptography.exceptions.InvalidSignature): signing.verify_django(django_request, public_key) diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py index 3517e8feb..bc10eae95 100644 --- a/api/tests/federation/test_tasks.py +++ b/api/tests/federation/test_tasks.py @@ -1,132 +1,119 @@ import datetime import os import pathlib -import pytest from django.core.paginator import Paginator from django.utils import timezone -from funkwhale_api.federation import serializers -from funkwhale_api.federation import tasks +from funkwhale_api.federation import serializers, tasks def test_scan_library_does_nothing_if_federation_disabled(mocker, factories): - library = factories['federation.Library'](federation_enabled=False) + library = factories["federation.Library"](federation_enabled=False) tasks.scan_library(library_id=library.pk) assert library.tracks.count() == 0 -def test_scan_library_page_does_nothing_if_federation_disabled( - mocker, factories): - library = factories['federation.Library'](federation_enabled=False) +def test_scan_library_page_does_nothing_if_federation_disabled(mocker, factories): + library = factories["federation.Library"](federation_enabled=False) tasks.scan_library_page(library_id=library.pk, page_url=None) assert library.tracks.count() == 0 -def test_scan_library_fetches_page_and_calls_scan_page( - mocker, factories, r_mock): +def test_scan_library_fetches_page_and_calls_scan_page(mocker, factories, r_mock): now = timezone.now() - library = factories['federation.Library'](federation_enabled=True) + library = factories["federation.Library"](federation_enabled=True) collection_conf = { - 'actor': library.actor, - 'id': library.url, - 'page_size': 10, - 'items': range(10), + "actor": library.actor, + "id": library.url, + "page_size": 10, + "items": range(10), } collection = serializers.PaginatedCollectionSerializer(collection_conf) - scan_page = mocker.patch( - 'funkwhale_api.federation.tasks.scan_library_page.delay') - r_mock.get(collection_conf['id'], json=collection.data) + scan_page = mocker.patch("funkwhale_api.federation.tasks.scan_library_page.delay") + r_mock.get(collection_conf["id"], json=collection.data) tasks.scan_library(library_id=library.pk) scan_page.assert_called_once_with( - library_id=library.id, - page_url=collection.data['first'], - until=None, + library_id=library.id, page_url=collection.data["first"], until=None ) library.refresh_from_db() assert library.fetched_date > now -def test_scan_page_fetches_page_and_creates_tracks( - mocker, factories, r_mock): - library = factories['federation.Library'](federation_enabled=True) - tfs = factories['music.TrackFile'].create_batch(size=5) +def test_scan_page_fetches_page_and_creates_tracks(mocker, factories, r_mock): + library = factories["federation.Library"](federation_enabled=True) + tfs = factories["music.TrackFile"].create_batch(size=5) page_conf = { - 'actor': library.actor, - 'id': library.url, - 'page': Paginator(tfs, 5).page(1), - 'item_serializer': serializers.AudioSerializer, + "actor": library.actor, + "id": library.url, + "page": Paginator(tfs, 5).page(1), + "item_serializer": serializers.AudioSerializer, } page = serializers.CollectionPageSerializer(page_conf) - r_mock.get(page.data['id'], json=page.data) + r_mock.get(page.data["id"], json=page.data) - tasks.scan_library_page(library_id=library.pk, page_url=page.data['id']) + tasks.scan_library_page(library_id=library.pk, page_url=page.data["id"]) - lts = list(library.tracks.all().order_by('-published_date')) + lts = list(library.tracks.all().order_by("-published_date")) assert len(lts) == 5 -def test_scan_page_trigger_next_page_scan_skip_if_same( - mocker, factories, r_mock): +def test_scan_page_trigger_next_page_scan_skip_if_same(mocker, factories, r_mock): patched_scan = mocker.patch( - 'funkwhale_api.federation.tasks.scan_library_page.delay' + "funkwhale_api.federation.tasks.scan_library_page.delay" ) - library = factories['federation.Library'](federation_enabled=True) - tfs = factories['music.TrackFile'].create_batch(size=1) + library = factories["federation.Library"](federation_enabled=True) + tfs = factories["music.TrackFile"].create_batch(size=1) page_conf = { - 'actor': library.actor, - 'id': library.url, - 'page': Paginator(tfs, 3).page(1), - 'item_serializer': serializers.AudioSerializer, + "actor": library.actor, + "id": library.url, + "page": Paginator(tfs, 3).page(1), + "item_serializer": serializers.AudioSerializer, } page = serializers.CollectionPageSerializer(page_conf) data = page.data - data['next'] = data['id'] - r_mock.get(page.data['id'], json=data) + data["next"] = data["id"] + r_mock.get(page.data["id"], json=data) - tasks.scan_library_page(library_id=library.pk, page_url=data['id']) + tasks.scan_library_page(library_id=library.pk, page_url=data["id"]) patched_scan.assert_not_called() -def test_scan_page_stops_once_until_is_reached( - mocker, factories, r_mock): - library = factories['federation.Library'](federation_enabled=True) - tfs = list(reversed(factories['music.TrackFile'].create_batch(size=5))) +def test_scan_page_stops_once_until_is_reached(mocker, factories, r_mock): + library = factories["federation.Library"](federation_enabled=True) + tfs = list(reversed(factories["music.TrackFile"].create_batch(size=5))) page_conf = { - 'actor': library.actor, - 'id': library.url, - 'page': Paginator(tfs, 3).page(1), - 'item_serializer': serializers.AudioSerializer, + "actor": library.actor, + "id": library.url, + "page": Paginator(tfs, 3).page(1), + "item_serializer": serializers.AudioSerializer, } page = serializers.CollectionPageSerializer(page_conf) - r_mock.get(page.data['id'], json=page.data) + r_mock.get(page.data["id"], json=page.data) tasks.scan_library_page( - library_id=library.pk, - page_url=page.data['id'], - until=tfs[1].creation_date) + library_id=library.pk, page_url=page.data["id"], until=tfs[1].creation_date + ) - lts = list(library.tracks.all().order_by('-published_date')) + lts = list(library.tracks.all().order_by("-published_date")) assert len(lts) == 2 for i, tf in enumerate(tfs[:1]): assert tf.creation_date == lts[i].published_date def test_clean_federation_music_cache_if_no_listen(preferences, factories): - preferences['federation__music_cache_duration'] = 60 - lt1 = factories['federation.LibraryTrack'](with_audio_file=True) - lt2 = factories['federation.LibraryTrack'](with_audio_file=True) - lt3 = factories['federation.LibraryTrack'](with_audio_file=True) - tf1 = factories['music.TrackFile']( - accessed_date=timezone.now(), library_track=lt1) - tf2 = factories['music.TrackFile']( - accessed_date=timezone.now()-datetime.timedelta(minutes=61), - library_track=lt2) - tf3 = factories['music.TrackFile']( - accessed_date=None, library_track=lt3) + preferences["federation__music_cache_duration"] = 60 + lt1 = factories["federation.LibraryTrack"](with_audio_file=True) + lt2 = factories["federation.LibraryTrack"](with_audio_file=True) + lt3 = factories["federation.LibraryTrack"](with_audio_file=True) + factories["music.TrackFile"](accessed_date=timezone.now(), library_track=lt1) + factories["music.TrackFile"]( + accessed_date=timezone.now() - datetime.timedelta(minutes=61), library_track=lt2 + ) + factories["music.TrackFile"](accessed_date=None, library_track=lt3) path1 = lt1.audio_file.path path2 = lt2.audio_file.path path3 = lt3.audio_file.path @@ -145,22 +132,19 @@ def test_clean_federation_music_cache_if_no_listen(preferences, factories): assert os.path.exists(path3) is False -def test_clean_federation_music_cache_orphaned( - settings, preferences, factories): - preferences['federation__music_cache_duration'] = 60 - path = os.path.join(settings.MEDIA_ROOT, 'federation_cache') - keep_path = os.path.join(os.path.join(path, '1a', 'b2'), 'keep.ogg') - remove_path = os.path.join(os.path.join(path, 'c3', 'd4'), 'remove.ogg') +def test_clean_federation_music_cache_orphaned(settings, preferences, factories): + preferences["federation__music_cache_duration"] = 60 + path = os.path.join(settings.MEDIA_ROOT, "federation_cache") + keep_path = os.path.join(os.path.join(path, "1a", "b2"), "keep.ogg") + remove_path = os.path.join(os.path.join(path, "c3", "d4"), "remove.ogg") os.makedirs(os.path.dirname(keep_path), exist_ok=True) os.makedirs(os.path.dirname(remove_path), exist_ok=True) pathlib.Path(keep_path).touch() pathlib.Path(remove_path).touch() - lt = factories['federation.LibraryTrack']( - with_audio_file=True, - audio_file__path=keep_path) - tf = factories['music.TrackFile']( - library_track=lt, - accessed_date=timezone.now()) + lt = factories["federation.LibraryTrack"]( + with_audio_file=True, audio_file__path=keep_path + ) + factories["music.TrackFile"](library_track=lt, accessed_date=timezone.now()) tasks.clean_music_cache() diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py index dc371ad9e..dbebe0fdc 100644 --- a/api/tests/federation/test_utils.py +++ b/api/tests/federation/test_utils.py @@ -3,12 +3,15 @@ import pytest from funkwhale_api.federation import utils -@pytest.mark.parametrize('url,path,expected', [ - ('http://test.com', '/hello', 'http://test.com/hello'), - ('http://test.com/', 'hello', 'http://test.com/hello'), - ('http://test.com/', '/hello', 'http://test.com/hello'), - ('http://test.com', 'hello', 'http://test.com/hello'), -]) +@pytest.mark.parametrize( + "url,path,expected", + [ + ("http://test.com", "/hello", "http://test.com/hello"), + ("http://test.com/", "hello", "http://test.com/hello"), + ("http://test.com/", "/hello", "http://test.com/hello"), + ("http://test.com", "hello", "http://test.com/hello"), + ], +) def test_full_url(settings, url, path, expected): settings.FUNKWHALE_URL = url assert utils.full_url(path) == expected @@ -16,33 +19,34 @@ def test_full_url(settings, url, path, expected): def test_extract_headers_from_meta(): wsgi_headers = { - 'HTTP_HOST': 'nginx', - 'HTTP_X_REAL_IP': '172.20.0.4', - 'HTTP_X_FORWARDED_FOR': '188.165.228.227, 172.20.0.4', - 'HTTP_X_FORWARDED_PROTO': 'http', - 'HTTP_X_FORWARDED_HOST': 'localhost:80', - 'HTTP_X_FORWARDED_PORT': '80', - 'HTTP_CONNECTION': 'close', - 'CONTENT_LENGTH': '1155', - 'CONTENT_TYPE': 'txt/application', - 'HTTP_SIGNATURE': 'Hello', - 'HTTP_DATE': 'Sat, 31 Mar 2018 13:53:55 GMT', - 'HTTP_USER_AGENT': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'} + "HTTP_HOST": "nginx", + "HTTP_X_REAL_IP": "172.20.0.4", + "HTTP_X_FORWARDED_FOR": "188.165.228.227, 172.20.0.4", + "HTTP_X_FORWARDED_PROTO": "http", + "HTTP_X_FORWARDED_HOST": "localhost:80", + "HTTP_X_FORWARDED_PORT": "80", + "HTTP_CONNECTION": "close", + "CONTENT_LENGTH": "1155", + "CONTENT_TYPE": "txt/application", + "HTTP_SIGNATURE": "Hello", + "HTTP_DATE": "Sat, 31 Mar 2018 13:53:55 GMT", + "HTTP_USER_AGENT": "http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)", + } cleaned_headers = utils.clean_wsgi_headers(wsgi_headers) expected = { - 'Host': 'nginx', - 'X-Real-Ip': '172.20.0.4', - 'X-Forwarded-For': '188.165.228.227, 172.20.0.4', - 'X-Forwarded-Proto': 'http', - 'X-Forwarded-Host': 'localhost:80', - 'X-Forwarded-Port': '80', - 'Connection': 'close', - 'Content-Length': '1155', - 'Content-Type': 'txt/application', - 'Signature': 'Hello', - 'Date': 'Sat, 31 Mar 2018 13:53:55 GMT', - 'User-Agent': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)' + "Host": "nginx", + "X-Real-Ip": "172.20.0.4", + "X-Forwarded-For": "188.165.228.227, 172.20.0.4", + "X-Forwarded-Proto": "http", + "X-Forwarded-Host": "localhost:80", + "X-Forwarded-Port": "80", + "Connection": "close", + "Content-Length": "1155", + "Content-Type": "txt/application", + "Signature": "Hello", + "Date": "Sat, 31 Mar 2018 13:53:55 GMT", + "User-Agent": "http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)", } assert cleaned_headers == expected diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 04a419aed..9e2d66a62 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -1,327 +1,308 @@ +import pytest from django.core.paginator import Paginator from django.urls import reverse from django.utils import timezone -import pytest - -from funkwhale_api.federation import actors -from funkwhale_api.federation import activity -from funkwhale_api.federation import models -from funkwhale_api.federation import serializers -from funkwhale_api.federation import utils -from funkwhale_api.federation import views -from funkwhale_api.federation import webfinger +from funkwhale_api.federation import ( + activity, + actors, + models, + serializers, + utils, + views, + webfinger, +) -@pytest.mark.parametrize('view,permissions', [ - (views.LibraryViewSet, ['federation']), - (views.LibraryTrackViewSet, ['federation']), -]) +@pytest.mark.parametrize( + "view,permissions", + [ + (views.LibraryViewSet, ["federation"]), + (views.LibraryTrackViewSet, ["federation"]), + ], +) def test_permissions(assert_user_permission, view, permissions): assert_user_permission(view, permissions) -@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys()) +@pytest.mark.parametrize("system_actor", actors.SYSTEM_ACTORS.keys()) def test_instance_actors(system_actor, db, api_client): actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance() - url = reverse( - 'federation:instance-actors-detail', - kwargs={'actor': system_actor}) + url = reverse("federation:instance-actors-detail", kwargs={"actor": system_actor}) response = api_client.get(url) serializer = serializers.ActorSerializer(actor) - if system_actor == 'library': - response.data.pop('url') + if system_actor == "library": + response.data.pop("url") assert response.status_code == 200 assert response.data == serializer.data -@pytest.mark.parametrize('route,kwargs', [ - ('instance-actors-outbox', {'actor': 'library'}), - ('instance-actors-inbox', {'actor': 'library'}), - ('instance-actors-detail', {'actor': 'library'}), - ('well-known-webfinger', {}), -]) +@pytest.mark.parametrize( + "route,kwargs", + [ + ("instance-actors-outbox", {"actor": "library"}), + ("instance-actors-inbox", {"actor": "library"}), + ("instance-actors-detail", {"actor": "library"}), + ("well-known-webfinger", {}), + ], +) def test_instance_endpoints_405_if_federation_disabled( - authenticated_actor, db, preferences, api_client, route, kwargs): - preferences['federation__enabled'] = False - url = reverse('federation:{}'.format(route), kwargs=kwargs) + authenticated_actor, db, preferences, api_client, route, kwargs +): + preferences["federation__enabled"] = False + url = reverse("federation:{}".format(route), kwargs=kwargs) response = api_client.get(url) assert response.status_code == 405 -def test_wellknown_webfinger_validates_resource( - db, api_client, settings, mocker): - clean = mocker.spy(webfinger, 'clean_resource') - url = reverse('federation:well-known-webfinger') - response = api_client.get(url, data={'resource': 'something'}) +def test_wellknown_webfinger_validates_resource(db, api_client, settings, mocker): + clean = mocker.spy(webfinger, "clean_resource") + url = reverse("federation:well-known-webfinger") + response = api_client.get(url, data={"resource": "something"}) - clean.assert_called_once_with('something') - assert url == '/.well-known/webfinger' + clean.assert_called_once_with("something") + assert url == "/.well-known/webfinger" assert response.status_code == 400 - assert response.data['errors']['resource'] == ( - 'Missing webfinger resource type' - ) + assert response.data["errors"]["resource"] == ("Missing webfinger resource type") -@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys()) -def test_wellknown_webfinger_system( - system_actor, db, api_client, settings, mocker): +@pytest.mark.parametrize("system_actor", actors.SYSTEM_ACTORS.keys()) +def test_wellknown_webfinger_system(system_actor, db, api_client, settings, mocker): actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance() - url = reverse('federation:well-known-webfinger') + url = reverse("federation:well-known-webfinger") response = api_client.get( url, - data={'resource': 'acct:{}'.format(actor.webfinger_subject)}, - HTTP_ACCEPT='application/jrd+json', + data={"resource": "acct:{}".format(actor.webfinger_subject)}, + HTTP_ACCEPT="application/jrd+json", ) serializer = serializers.ActorWebfingerSerializer(actor) assert response.status_code == 200 - assert response['Content-Type'] == 'application/jrd+json' + assert response["Content-Type"] == "application/jrd+json" assert response.data == serializer.data def test_wellknown_nodeinfo(db, preferences, api_client, settings): expected = { - 'links': [ + "links": [ { - 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', - 'href': '{}{}'.format( - settings.FUNKWHALE_URL, - reverse('api:v1:instance:nodeinfo-2.0') - ) + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": "{}{}".format( + settings.FUNKWHALE_URL, reverse("api:v1:instance:nodeinfo-2.0") + ), } ] } - url = reverse('federation:well-known-nodeinfo') - response = api_client.get(url, HTTP_ACCEPT='application/jrd+json') + url = reverse("federation:well-known-nodeinfo") + response = api_client.get(url, HTTP_ACCEPT="application/jrd+json") assert response.status_code == 200 - assert response['Content-Type'] == 'application/jrd+json' + assert response["Content-Type"] == "application/jrd+json" assert response.data == expected def test_wellknown_nodeinfo_disabled(db, preferences, api_client): - preferences['instance__nodeinfo_enabled'] = False - url = reverse('federation:well-known-nodeinfo') + preferences["instance__nodeinfo_enabled"] = False + url = reverse("federation:well-known-nodeinfo") response = api_client.get(url) assert response.status_code == 404 -def test_audio_file_list_requires_authenticated_actor( - db, preferences, api_client): - preferences['federation__music_needs_approval'] = True - url = reverse('federation:music:files-list') +def test_audio_file_list_requires_authenticated_actor(db, preferences, api_client): + preferences["federation__music_needs_approval"] = True + url = reverse("federation:music:files-list") response = api_client.get(url) assert response.status_code == 403 -def test_audio_file_list_actor_no_page( - db, preferences, api_client, factories): - preferences['federation__music_needs_approval'] = False - preferences['federation__collection_page_size'] = 2 - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() - tfs = factories['music.TrackFile'].create_batch(size=5) +def test_audio_file_list_actor_no_page(db, preferences, api_client, factories): + preferences["federation__music_needs_approval"] = False + preferences["federation__collection_page_size"] = 2 + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() + tfs = factories["music.TrackFile"].create_batch(size=5) conf = { - 'id': utils.full_url(reverse('federation:music:files-list')), - 'page_size': 2, - 'items': list(reversed(tfs)), # we order by -creation_date - 'item_serializer': serializers.AudioSerializer, - 'actor': library + "id": utils.full_url(reverse("federation:music:files-list")), + "page_size": 2, + "items": list(reversed(tfs)), # we order by -creation_date + "item_serializer": serializers.AudioSerializer, + "actor": library, } expected = serializers.PaginatedCollectionSerializer(conf).data - url = reverse('federation:music:files-list') + url = reverse("federation:music:files-list") response = api_client.get(url) assert response.status_code == 200 assert response.data == expected -def test_audio_file_list_actor_page( - db, preferences, api_client, factories): - preferences['federation__music_needs_approval'] = False - preferences['federation__collection_page_size'] = 2 - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() - tfs = factories['music.TrackFile'].create_batch(size=5) +def test_audio_file_list_actor_page(db, preferences, api_client, factories): + preferences["federation__music_needs_approval"] = False + preferences["federation__collection_page_size"] = 2 + library = actors.SYSTEM_ACTORS["library"].get_actor_instance() + tfs = factories["music.TrackFile"].create_batch(size=5) conf = { - 'id': utils.full_url(reverse('federation:music:files-list')), - 'page': Paginator(list(reversed(tfs)), 2).page(2), - 'item_serializer': serializers.AudioSerializer, - 'actor': library + "id": utils.full_url(reverse("federation:music:files-list")), + "page": Paginator(list(reversed(tfs)), 2).page(2), + "item_serializer": serializers.AudioSerializer, + "actor": library, } expected = serializers.CollectionPageSerializer(conf).data - url = reverse('federation:music:files-list') - response = api_client.get(url, data={'page': 2}) + url = reverse("federation:music:files-list") + response = api_client.get(url, data={"page": 2}) assert response.status_code == 200 assert response.data == expected def test_audio_file_list_actor_page_exclude_federated_files( - db, preferences, api_client, factories): - preferences['federation__music_needs_approval'] = False - library = actors.SYSTEM_ACTORS['library'].get_actor_instance() - tfs = factories['music.TrackFile'].create_batch(size=5, federation=True) + db, preferences, api_client, factories +): + preferences["federation__music_needs_approval"] = False + factories["music.TrackFile"].create_batch(size=5, federation=True) - url = reverse('federation:music:files-list') + url = reverse("federation:music:files-list") response = api_client.get(url) assert response.status_code == 200 - assert response.data['totalItems'] == 0 + assert response.data["totalItems"] == 0 -def test_audio_file_list_actor_page_error( - db, preferences, api_client, factories): - preferences['federation__music_needs_approval'] = False - url = reverse('federation:music:files-list') - response = api_client.get(url, data={'page': 'nope'}) +def test_audio_file_list_actor_page_error(db, preferences, api_client, factories): + preferences["federation__music_needs_approval"] = False + url = reverse("federation:music:files-list") + response = api_client.get(url, data={"page": "nope"}) assert response.status_code == 400 def test_audio_file_list_actor_page_error_too_far( - db, preferences, api_client, factories): - preferences['federation__music_needs_approval'] = False - url = reverse('federation:music:files-list') - response = api_client.get(url, data={'page': 5000}) + db, preferences, api_client, factories +): + preferences["federation__music_needs_approval"] = False + url = reverse("federation:music:files-list") + response = api_client.get(url, data={"page": 5000}) assert response.status_code == 404 def test_library_actor_includes_library_link(db, preferences, api_client): - actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - url = reverse( - 'federation:instance-actors-detail', - kwargs={'actor': 'library'}) + url = reverse("federation:instance-actors-detail", kwargs={"actor": "library"}) response = api_client.get(url) expected_links = [ { - 'type': 'Link', - 'name': 'library', - 'mediaType': 'application/activity+json', - 'href': utils.full_url(reverse('federation:music:files-list')) + "type": "Link", + "name": "library", + "mediaType": "application/activity+json", + "href": utils.full_url(reverse("federation:music:files-list")), } ] assert response.status_code == 200 - assert response.data['url'] == expected_links + assert response.data["url"] == expected_links def test_can_fetch_library(superuser_api_client, mocker): - result = {'test': 'test'} + result = {"test": "test"} scan = mocker.patch( - 'funkwhale_api.federation.library.scan_from_account_name', - return_value=result) + "funkwhale_api.federation.library.scan_from_account_name", return_value=result + ) - url = reverse('api:v1:federation:libraries-fetch') - response = superuser_api_client.get( - url, data={'account': 'test@test.library'}) + url = reverse("api:v1:federation:libraries-fetch") + response = superuser_api_client.get(url, data={"account": "test@test.library"}) assert response.status_code == 200 assert response.data == result - scan.assert_called_once_with('test@test.library') + scan.assert_called_once_with("test@test.library") def test_follow_library(superuser_api_client, mocker, factories, r_mock): - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - actor = factories['federation.Actor']() - follow = {'test': 'follow'} - on_commit = mocker.patch( - 'funkwhale_api.common.utils.on_commit') + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + actor = factories["federation.Actor"]() + follow = {"test": "follow"} + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") actor_data = serializers.ActorSerializer(actor).data - actor_data['url'] = [{ - 'href': 'https://test.library', - 'name': 'library', - 'type': 'Link', - }] + actor_data["url"] = [ + {"href": "https://test.library", "name": "library", "type": "Link"} + ] library_conf = { - 'id': 'https://test.library', - 'items': range(10), - 'actor': actor, - 'page_size': 5, + "id": "https://test.library", + "items": range(10), + "actor": actor, + "page_size": 5, } library_data = serializers.PaginatedCollectionSerializer(library_conf).data r_mock.get(actor.url, json=actor_data) - r_mock.get('https://test.library', json=library_data) + r_mock.get("https://test.library", json=library_data) data = { - 'actor': actor.url, - 'autoimport': False, - 'federation_enabled': True, - 'download_files': False, + "actor": actor.url, + "autoimport": False, + "federation_enabled": True, + "download_files": False, } - url = reverse('api:v1:federation:libraries-list') - response = superuser_api_client.post( - url, data) + url = reverse("api:v1:federation:libraries-list") + response = superuser_api_client.post(url, data) assert response.status_code == 201 - follow = models.Follow.objects.get( - actor=library_actor, - target=actor, - approved=None, - ) + follow = models.Follow.objects.get(actor=library_actor, target=actor, approved=None) library = follow.library - assert response.data == serializers.APILibraryCreateSerializer( - library).data + assert response.data == serializers.APILibraryCreateSerializer(library).data on_commit.assert_called_once_with( activity.deliver, serializers.FollowSerializer(follow).data, on_behalf_of=library_actor, - to=[actor.url] + to=[actor.url], ) def test_can_list_system_actor_following(factories, superuser_api_client): - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow1 = factories['federation.Follow'](actor=library_actor) - follow2 = factories['federation.Follow']() + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + follow1 = factories["federation.Follow"](actor=library_actor) + factories["federation.Follow"]() - url = reverse('api:v1:federation:libraries-following') + url = reverse("api:v1:federation:libraries-following") response = superuser_api_client.get(url) assert response.status_code == 200 - assert response.data['results'] == [ - serializers.APIFollowSerializer(follow1).data - ] + assert response.data["results"] == [serializers.APIFollowSerializer(follow1).data] def test_can_list_system_actor_followers(factories, superuser_api_client): - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow1 = factories['federation.Follow'](actor=library_actor) - follow2 = factories['federation.Follow'](target=library_actor) + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + factories["federation.Follow"](actor=library_actor) + follow2 = factories["federation.Follow"](target=library_actor) - url = reverse('api:v1:federation:libraries-followers') + url = reverse("api:v1:federation:libraries-followers") response = superuser_api_client.get(url) assert response.status_code == 200 - assert response.data['results'] == [ - serializers.APIFollowSerializer(follow2).data - ] + assert response.data["results"] == [serializers.APIFollowSerializer(follow2).data] def test_can_list_libraries(factories, superuser_api_client): - library1 = factories['federation.Library']() - library2 = factories['federation.Library']() + library1 = factories["federation.Library"]() + library2 = factories["federation.Library"]() - url = reverse('api:v1:federation:libraries-list') + url = reverse("api:v1:federation:libraries-list") response = superuser_api_client.get(url) assert response.status_code == 200 - assert response.data['results'] == [ + assert response.data["results"] == [ serializers.APILibrarySerializer(library1).data, serializers.APILibrarySerializer(library2).data, ] def test_can_detail_library(factories, superuser_api_client): - library = factories['federation.Library']() + library = factories["federation.Library"]() url = reverse( - 'api:v1:federation:libraries-detail', - kwargs={'uuid': str(library.uuid)}) + "api:v1:federation:libraries-detail", kwargs={"uuid": str(library.uuid)} + ) response = superuser_api_client.get(url) assert response.status_code == 200 @@ -329,15 +310,15 @@ def test_can_detail_library(factories, superuser_api_client): def test_can_patch_library(factories, superuser_api_client): - library = factories['federation.Library']() + library = factories["federation.Library"]() data = { - 'federation_enabled': not library.federation_enabled, - 'download_files': not library.download_files, - 'autoimport': not library.autoimport, + "federation_enabled": not library.federation_enabled, + "download_files": not library.download_files, + "autoimport": not library.autoimport, } url = reverse( - 'api:v1:federation:libraries-detail', - kwargs={'uuid': str(library.uuid)}) + "api:v1:federation:libraries-detail", kwargs={"uuid": str(library.uuid)} + ) response = superuser_api_client.patch(url, data) assert response.status_code == 200 @@ -349,55 +330,49 @@ def test_can_patch_library(factories, superuser_api_client): def test_scan_library(factories, mocker, superuser_api_client): scan = mocker.patch( - 'funkwhale_api.federation.tasks.scan_library.delay', - return_value=mocker.Mock(id='id')) - library = factories['federation.Library']() + "funkwhale_api.federation.tasks.scan_library.delay", + return_value=mocker.Mock(id="id"), + ) + library = factories["federation.Library"]() now = timezone.now() - data = { - 'until': now, - } + data = {"until": now} url = reverse( - 'api:v1:federation:libraries-scan', - kwargs={'uuid': str(library.uuid)}) + "api:v1:federation:libraries-scan", kwargs={"uuid": str(library.uuid)} + ) response = superuser_api_client.post(url, data) assert response.status_code == 200 - assert response.data == {'task': 'id'} - scan.assert_called_once_with( - library_id=library.pk, - until=now - ) + assert response.data == {"task": "id"} + scan.assert_called_once_with(library_id=library.pk, until=now) def test_list_library_tracks(factories, superuser_api_client): - library = factories['federation.Library']() - lts = list(reversed(factories['federation.LibraryTrack'].create_batch( - size=5, library=library))) - factories['federation.LibraryTrack'].create_batch(size=5) - url = reverse('api:v1:federation:library-tracks-list') - response = superuser_api_client.get(url, {'library': library.uuid}) + library = factories["federation.Library"]() + lts = list( + reversed( + factories["federation.LibraryTrack"].create_batch(size=5, library=library) + ) + ) + factories["federation.LibraryTrack"].create_batch(size=5) + url = reverse("api:v1:federation:library-tracks-list") + response = superuser_api_client.get(url, {"library": library.uuid}) assert response.status_code == 200 assert response.data == { - 'results': serializers.APILibraryTrackSerializer(lts, many=True).data, - 'count': 5, - 'previous': None, - 'next': None, + "results": serializers.APILibraryTrackSerializer(lts, many=True).data, + "count": 5, + "previous": None, + "next": None, } def test_can_update_follow_status(factories, superuser_api_client, mocker): - patched_accept = mocker.patch( - 'funkwhale_api.federation.activity.accept_follow' - ) - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow = factories['federation.Follow'](target=library_actor) + patched_accept = mocker.patch("funkwhale_api.federation.activity.accept_follow") + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + follow = factories["federation.Follow"](target=library_actor) - payload = { - 'follow': follow.pk, - 'approved': True - } - url = reverse('api:v1:federation:libraries-followers') + payload = {"follow": follow.pk, "approved": True} + url = reverse("api:v1:federation:libraries-followers") response = superuser_api_client.patch(url, payload) follow.refresh_from_db() @@ -407,45 +382,33 @@ def test_can_update_follow_status(factories, superuser_api_client, mocker): def test_can_filter_pending_follows(factories, superuser_api_client): - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow = factories['federation.Follow']( - target=library_actor, - approved=True) + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + factories["federation.Follow"](target=library_actor, approved=True) - params = {'pending': True} - url = reverse('api:v1:federation:libraries-followers') + params = {"pending": True} + url = reverse("api:v1:federation:libraries-followers") response = superuser_api_client.get(url, params) assert response.status_code == 200 - assert len(response.data['results']) == 0 + assert len(response.data["results"]) == 0 -def test_library_track_action_import( - factories, superuser_api_client, mocker): - lt1 = factories['federation.LibraryTrack']() - lt2 = factories['federation.LibraryTrack'](library=lt1.library) - lt3 = factories['federation.LibraryTrack']() - lt4 = factories['federation.LibraryTrack'](library=lt3.library) - mocked_run = mocker.patch( - 'funkwhale_api.music.tasks.import_batch_run.delay') +def test_library_track_action_import(factories, superuser_api_client, mocker): + lt1 = factories["federation.LibraryTrack"]() + lt2 = factories["federation.LibraryTrack"](library=lt1.library) + lt3 = factories["federation.LibraryTrack"]() + factories["federation.LibraryTrack"](library=lt3.library) + mocked_run = mocker.patch("funkwhale_api.music.tasks.import_batch_run.delay") payload = { - 'objects': 'all', - 'action': 'import', - 'filters': { - 'library': lt1.library.uuid - } - } - url = reverse('api:v1:federation:library-tracks-action') - response = superuser_api_client.post(url, payload, format='json') - batch = superuser_api_client.user.imports.latest('id') - expected = { - 'updated': 2, - 'action': 'import', - 'result': { - 'batch': {'id': batch.pk} - } + "objects": "all", + "action": "import", + "filters": {"library": lt1.library.uuid}, } + url = reverse("api:v1:federation:library-tracks-action") + response = superuser_api_client.post(url, payload, format="json") + batch = superuser_api_client.user.imports.latest("id") + expected = {"updated": 2, "action": "import", "result": {"batch": {"id": batch.pk}}} imported_lts = [lt1, lt2] assert response.status_code == 200 diff --git a/api/tests/federation/test_webfinger.py b/api/tests/federation/test_webfinger.py index 4b8dca207..0608df3e2 100644 --- a/api/tests/federation/test_webfinger.py +++ b/api/tests/federation/test_webfinger.py @@ -1,22 +1,23 @@ import pytest - from django import forms -from django.urls import reverse from funkwhale_api.federation import webfinger def test_webfinger_clean_resource(): - t, r = webfinger.clean_resource('acct:service@test.federation') - assert t == 'acct' - assert r == 'service@test.federation' + t, r = webfinger.clean_resource("acct:service@test.federation") + assert t == "acct" + assert r == "service@test.federation" -@pytest.mark.parametrize('resource,message', [ - ('', 'Invalid resource string'), - ('service@test.com', 'Missing webfinger resource type'), - ('noop:service@test.com', 'Invalid webfinger resource type'), -]) +@pytest.mark.parametrize( + "resource,message", + [ + ("", "Invalid resource string"), + ("service@test.com", "Missing webfinger resource type"), + ("noop:service@test.com", "Invalid webfinger resource type"), + ], +) def test_webfinger_clean_resource_errors(resource, message): with pytest.raises(forms.ValidationError) as excinfo: webfinger.clean_resource(resource) @@ -25,16 +26,19 @@ def test_webfinger_clean_resource_errors(resource, message): def test_webfinger_clean_acct(settings): - username, hostname = webfinger.clean_acct('library@test.federation') - assert username == 'library' - assert hostname == 'test.federation' + username, hostname = webfinger.clean_acct("library@test.federation") + assert username == "library" + assert hostname == "test.federation" -@pytest.mark.parametrize('resource,message', [ - ('service', 'Invalid format'), - ('service@test.com', 'Invalid hostname test.com'), - ('noop@test.federation', 'Invalid account'), -]) +@pytest.mark.parametrize( + "resource,message", + [ + ("service", "Invalid format"), + ("service@test.com", "Invalid hostname test.com"), + ("noop@test.federation", "Invalid account"), + ], +) def test_webfinger_clean_acct_errors(resource, message, settings): with pytest.raises(forms.ValidationError) as excinfo: webfinger.clean_resource(resource) @@ -43,26 +47,24 @@ def test_webfinger_clean_acct_errors(resource, message, settings): def test_webfinger_get_resource(r_mock): - resource = 'acct:test@test.webfinger' + resource = "acct:test@test.webfinger" payload = { - 'subject': resource, - 'aliases': ['https://test.webfinger'], - 'links': [ + "subject": resource, + "aliases": ["https://test.webfinger"], + "links": [ { - 'rel': 'self', - 'type': 'application/activity+json', - 'href': 'https://test.webfinger/user/test' + "rel": "self", + "type": "application/activity+json", + "href": "https://test.webfinger/user/test", } - ] + ], } r_mock.get( - 'https://test.webfinger/.well-known/webfinger?resource={}'.format( - resource - ), - json=payload + "https://test.webfinger/.well-known/webfinger?resource={}".format(resource), + json=payload, ) - data = webfinger.get_resource('acct:test@test.webfinger') + data = webfinger.get_resource("acct:test@test.webfinger") - assert data['actor_url'] == 'https://test.webfinger/user/test' - assert data['subject'] == resource + assert data["actor_url"] == "https://test.webfinger/user/test" + assert data["subject"] == resource diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py index 04000604b..f3ada5052 100644 --- a/api/tests/history/test_activity.py +++ b/api/tests/history/test_activity.py @@ -1,19 +1,17 @@ -from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.history import activities, serializers from funkwhale_api.music.serializers import TrackActivitySerializer -from funkwhale_api.history import serializers -from funkwhale_api.history import activities +from funkwhale_api.users.serializers import UserActivitySerializer def test_get_listening_activity_url(settings, factories): - listening = factories['history.Listening']() + listening = factories["history.Listening"]() user_url = listening.user.get_activity_url() - expected = '{}/listenings/tracks/{}'.format( - user_url, listening.pk) + expected = "{}/listenings/tracks/{}".format(user_url, listening.pk) assert listening.get_activity_url() == expected def test_activity_listening_serializer(factories): - listening = factories['history.Listening']() + listening = factories["history.Listening"]() actor = UserActivitySerializer(listening.user).data field = serializers.serializers.DateTimeField() @@ -32,44 +30,30 @@ def test_activity_listening_serializer(factories): def test_track_listening_serializer_is_connected(activity_registry): - conf = activity_registry['history.Listening'] - assert conf['serializer'] == serializers.ListeningActivitySerializer + conf = activity_registry["history.Listening"] + assert conf["serializer"] == serializers.ListeningActivitySerializer -def test_track_listening_serializer_instance_activity_consumer( - activity_registry): - conf = activity_registry['history.Listening'] +def test_track_listening_serializer_instance_activity_consumer(activity_registry): + conf = activity_registry["history.Listening"] consumer = activities.broadcast_listening_to_instance_activity - assert consumer in conf['consumers'] + assert consumer in conf["consumers"] -def test_broadcast_listening_to_instance_activity( - factories, mocker): - p = mocker.patch('funkwhale_api.common.channels.group_send') - listening = factories['history.Listening']() +def test_broadcast_listening_to_instance_activity(factories, mocker): + p = mocker.patch("funkwhale_api.common.channels.group_send") + listening = factories["history.Listening"]() data = serializers.ListeningActivitySerializer(listening).data consumer = activities.broadcast_listening_to_instance_activity - message = { - "type": 'event.send', - "text": '', - "data": data - } + message = {"type": "event.send", "text": "", "data": data} consumer(data=data, obj=listening) - p.assert_called_once_with('instance_activity', message) + p.assert_called_once_with("instance_activity", message) -def test_broadcast_listening_to_instance_activity_private( - factories, mocker): - p = mocker.patch('funkwhale_api.common.channels.group_send') - listening = factories['history.Listening']( - user__privacy_level='me' - ) +def test_broadcast_listening_to_instance_activity_private(factories, mocker): + p = mocker.patch("funkwhale_api.common.channels.group_send") + listening = factories["history.Listening"](user__privacy_level="me") data = serializers.ListeningActivitySerializer(listening).data consumer = activities.broadcast_listening_to_instance_activity - message = { - "type": 'event.send', - "text": '', - "data": data - } consumer(data=data, obj=listening) p.assert_not_called() diff --git a/api/tests/history/test_history.py b/api/tests/history/test_history.py index 202725596..9cc4e3d14 100644 --- a/api/tests/history/test_history.py +++ b/api/tests/history/test_history.py @@ -1,43 +1,36 @@ -import random -import json from django.urls import reverse -from django.core.exceptions import ValidationError -from django.utils import timezone from funkwhale_api.history import models def test_can_create_listening(factories): - track = factories['music.Track']() - user = factories['users.User']() - now = timezone.now() - l = models.Listening.objects.create(user=user, track=track) + track = factories["music.Track"]() + user = factories["users.User"]() + models.Listening.objects.create(user=user, track=track) def test_logged_in_user_can_create_listening_via_api( - logged_in_client, factories, activity_muted): - track = factories['music.Track']() + logged_in_client, factories, activity_muted +): + track = factories["music.Track"]() - url = reverse('api:v1:history:listenings-list') - response = logged_in_client.post(url, { - 'track': track.pk, - }) + url = reverse("api:v1:history:listenings-list") + logged_in_client.post(url, {"track": track.pk}) - listening = models.Listening.objects.latest('id') + listening = models.Listening.objects.latest("id") assert listening.track == track assert listening.user == logged_in_client.user def test_adding_listening_calls_activity_record( - factories, logged_in_client, activity_muted): - track = factories['music.Track']() + factories, logged_in_client, activity_muted +): + track = factories["music.Track"]() - url = reverse('api:v1:history:listenings-list') - response = logged_in_client.post(url, { - 'track': track.pk, - }) + url = reverse("api:v1:history:listenings-list") + logged_in_client.post(url, {"track": track.pk}) - listening = models.Listening.objects.latest('id') + listening = models.Listening.objects.latest("id") activity_muted.assert_called_once_with(listening) diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py index 87b888288..181ddf277 100644 --- a/api/tests/instance/test_nodeinfo.py +++ b/api/tests/instance/test_nodeinfo.py @@ -1,107 +1,79 @@ -from django.urls import reverse import funkwhale_api - from funkwhale_api.instance import nodeinfo def test_nodeinfo_dump(preferences, mocker): - preferences['instance__nodeinfo_stats_enabled'] = True + preferences["instance__nodeinfo_stats_enabled"] = True stats = { - 'users': 1, - 'tracks': 2, - 'albums': 3, - 'artists': 4, - 'track_favorites': 5, - 'music_duration': 6, - 'listenings': 7, + "users": 1, + "tracks": 2, + "albums": 3, + "artists": 4, + "track_favorites": 5, + "music_duration": 6, + "listenings": 7, } - mocker.patch('funkwhale_api.instance.stats.get', return_value=stats) + mocker.patch("funkwhale_api.instance.stats.get", return_value=stats) expected = { - 'version': '2.0', - 'software': { - 'name': 'funkwhale', - 'version': funkwhale_api.__version__ - }, - 'protocols': ['activitypub'], - 'services': { - 'inbound': [], - 'outbound': [] - }, - 'openRegistrations': preferences['users__registration_enabled'], - 'usage': { - 'users': { - 'total': stats['users'], - } - }, - 'metadata': { - 'private': preferences['instance__nodeinfo_private'], - 'shortDescription': preferences['instance__short_description'], - 'longDescription': preferences['instance__long_description'], - 'nodeName': preferences['instance__name'], - 'library': { - 'federationEnabled': preferences['federation__enabled'], - 'federationNeedsApproval': preferences['federation__music_needs_approval'], - 'anonymousCanListen': preferences['common__api_authentication_required'], - 'tracks': { - 'total': stats['tracks'], - }, - 'artists': { - 'total': stats['artists'], - }, - 'albums': { - 'total': stats['albums'], - }, - 'music': { - 'hours': stats['music_duration'] - }, + "version": "2.0", + "software": {"name": "funkwhale", "version": funkwhale_api.__version__}, + "protocols": ["activitypub"], + "services": {"inbound": [], "outbound": []}, + "openRegistrations": preferences["users__registration_enabled"], + "usage": {"users": {"total": stats["users"]}}, + "metadata": { + "private": preferences["instance__nodeinfo_private"], + "shortDescription": preferences["instance__short_description"], + "longDescription": preferences["instance__long_description"], + "nodeName": preferences["instance__name"], + "library": { + "federationEnabled": preferences["federation__enabled"], + "federationNeedsApproval": preferences[ + "federation__music_needs_approval" + ], + "anonymousCanListen": preferences[ + "common__api_authentication_required" + ], + "tracks": {"total": stats["tracks"]}, + "artists": {"total": stats["artists"]}, + "albums": {"total": stats["albums"]}, + "music": {"hours": stats["music_duration"]}, }, - 'usage': { - 'favorites': { - 'tracks': { - 'total': stats['track_favorites'], - } - }, - 'listenings': { - 'total': stats['listenings'] - } - } - } + "usage": { + "favorites": {"tracks": {"total": stats["track_favorites"]}}, + "listenings": {"total": stats["listenings"]}, + }, + }, } assert nodeinfo.get() == expected def test_nodeinfo_dump_stats_disabled(preferences, mocker): - preferences['instance__nodeinfo_stats_enabled'] = False + preferences["instance__nodeinfo_stats_enabled"] = False expected = { - 'version': '2.0', - 'software': { - 'name': 'funkwhale', - 'version': funkwhale_api.__version__ - }, - 'protocols': ['activitypub'], - 'services': { - 'inbound': [], - 'outbound': [] - }, - 'openRegistrations': preferences['users__registration_enabled'], - 'usage': { - 'users': { - 'total': 0, - } - }, - 'metadata': { - 'private': preferences['instance__nodeinfo_private'], - 'shortDescription': preferences['instance__short_description'], - 'longDescription': preferences['instance__long_description'], - 'nodeName': preferences['instance__name'], - 'library': { - 'federationEnabled': preferences['federation__enabled'], - 'federationNeedsApproval': preferences['federation__music_needs_approval'], - 'anonymousCanListen': preferences['common__api_authentication_required'], + "version": "2.0", + "software": {"name": "funkwhale", "version": funkwhale_api.__version__}, + "protocols": ["activitypub"], + "services": {"inbound": [], "outbound": []}, + "openRegistrations": preferences["users__registration_enabled"], + "usage": {"users": {"total": 0}}, + "metadata": { + "private": preferences["instance__nodeinfo_private"], + "shortDescription": preferences["instance__short_description"], + "longDescription": preferences["instance__long_description"], + "nodeName": preferences["instance__name"], + "library": { + "federationEnabled": preferences["federation__enabled"], + "federationNeedsApproval": preferences[ + "federation__music_needs_approval" + ], + "anonymousCanListen": preferences[ + "common__api_authentication_required" + ], }, - } + }, } assert nodeinfo.get() == expected diff --git a/api/tests/instance/test_preferences.py b/api/tests/instance/test_preferences.py index beb8e6d33..b465be9d3 100644 --- a/api/tests/instance/test_preferences.py +++ b/api/tests/instance/test_preferences.py @@ -1,17 +1,15 @@ import pytest - from django.urls import reverse -from dynamic_preferences.api import serializers - def test_can_list_settings_via_api(preferences, api_client): - url = reverse('api:v1:instance:settings') + url = reverse("api:v1:instance:settings") all_preferences = preferences.model.objects.all() expected_preferences = { p.preference.identifier(): p for p in all_preferences - if getattr(p.preference, 'show_in_api', False)} + if getattr(p.preference, "show_in_api", False) + } assert len(expected_preferences) > 0 @@ -20,15 +18,18 @@ def test_can_list_settings_via_api(preferences, api_client): assert len(response.data) == len(expected_preferences) for p in response.data: - i = '__'.join([p['section'], p['name']]) + i = "__".join([p["section"], p["name"]]) assert i in expected_preferences -@pytest.mark.parametrize('pref,value', [ - ('instance__name', 'My instance'), - ('instance__short_description', 'For music lovers'), - ('instance__long_description', 'For real music lovers'), -]) +@pytest.mark.parametrize( + "pref,value", + [ + ("instance__name", "My instance"), + ("instance__short_description", "For music lovers"), + ("instance__long_description", "For real music lovers"), + ], +) def test_instance_settings(pref, value, preferences): preferences[pref] = value diff --git a/api/tests/instance/test_stats.py b/api/tests/instance/test_stats.py index 6063e9300..1d8bcfc0a 100644 --- a/api/tests/instance/test_stats.py +++ b/api/tests/instance/test_stats.py @@ -1,17 +1,14 @@ -from django.urls import reverse - from funkwhale_api.instance import stats def test_get_users(mocker): - mocker.patch( - 'funkwhale_api.users.models.User.objects.count', return_value=42) + mocker.patch("funkwhale_api.users.models.User.objects.count", return_value=42) assert stats.get_users() == 42 def test_get_music_duration(factories): - factories['music.TrackFile'].create_batch(size=5, duration=360) + factories["music.TrackFile"].create_batch(size=5, duration=360) # duration is in hours assert stats.get_music_duration() == 0.5 @@ -19,56 +16,48 @@ def test_get_music_duration(factories): def test_get_listenings(mocker): mocker.patch( - 'funkwhale_api.history.models.Listening.objects.count', - return_value=42) + "funkwhale_api.history.models.Listening.objects.count", return_value=42 + ) assert stats.get_listenings() == 42 def test_get_track_favorites(mocker): mocker.patch( - 'funkwhale_api.favorites.models.TrackFavorite.objects.count', - return_value=42) + "funkwhale_api.favorites.models.TrackFavorite.objects.count", return_value=42 + ) assert stats.get_track_favorites() == 42 def test_get_tracks(mocker): - mocker.patch( - 'funkwhale_api.music.models.Track.objects.count', - return_value=42) + mocker.patch("funkwhale_api.music.models.Track.objects.count", return_value=42) assert stats.get_tracks() == 42 def test_get_albums(mocker): - mocker.patch( - 'funkwhale_api.music.models.Album.objects.count', - return_value=42) + mocker.patch("funkwhale_api.music.models.Album.objects.count", return_value=42) assert stats.get_albums() == 42 def test_get_artists(mocker): - mocker.patch( - 'funkwhale_api.music.models.Artist.objects.count', - return_value=42) + mocker.patch("funkwhale_api.music.models.Artist.objects.count", return_value=42) assert stats.get_artists() == 42 def test_get(mocker): keys = [ - 'users', - 'tracks', - 'albums', - 'artists', - 'track_favorites', - 'listenings', - 'music_duration', + "users", + "tracks", + "albums", + "artists", + "track_favorites", + "listenings", + "music_duration", ] - mocks = [ - mocker.patch.object(stats, 'get_{}'.format(k), return_value=i) + [ + mocker.patch.object(stats, "get_{}".format(k), return_value=i) for i, k in enumerate(keys) ] - expected = { - k: i for i, k in enumerate(keys) - } + expected = {k: i for i, k in enumerate(keys)} assert stats.get() == expected diff --git a/api/tests/instance/test_views.py b/api/tests/instance/test_views.py index daf54db51..6dd4f6345 100644 --- a/api/tests/instance/test_views.py +++ b/api/tests/instance/test_views.py @@ -1,62 +1,54 @@ import pytest - from django.urls import reverse from funkwhale_api.instance import views -@pytest.mark.parametrize('view,permissions', [ - (views.AdminSettings, ['settings']), -]) +@pytest.mark.parametrize("view,permissions", [(views.AdminSettings, ["settings"])]) def test_permissions(assert_user_permission, view, permissions): assert_user_permission(view, permissions) def test_nodeinfo_endpoint(db, api_client, mocker): - payload = { - 'test': 'test' - } - mocked_nodeinfo = mocker.patch( - 'funkwhale_api.instance.nodeinfo.get', return_value=payload) - url = reverse('api:v1:instance:nodeinfo-2.0') + payload = {"test": "test"} + mocker.patch("funkwhale_api.instance.nodeinfo.get", return_value=payload) + url = reverse("api:v1:instance:nodeinfo-2.0") response = api_client.get(url) - ct = 'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8' # noqa + ct = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" # noqa assert response.status_code == 200 - assert response['Content-Type'] == ct + assert response["Content-Type"] == ct assert response.data == payload def test_nodeinfo_endpoint_disabled(db, api_client, preferences): - preferences['instance__nodeinfo_enabled'] = False - url = reverse('api:v1:instance:nodeinfo-2.0') + preferences["instance__nodeinfo_enabled"] = False + url = reverse("api:v1:instance:nodeinfo-2.0") response = api_client.get(url) assert response.status_code == 404 def test_settings_only_list_public_settings(db, api_client, preferences): - url = reverse('api:v1:instance:settings') + url = reverse("api:v1:instance:settings") response = api_client.get(url) for conf in response.data: - p = preferences.model.objects.get( - section=conf['section'], name=conf['name']) + p = preferences.model.objects.get(section=conf["section"], name=conf["name"]) assert p.preference.show_in_api is True def test_admin_settings_restrict_access(db, logged_in_api_client, preferences): - url = reverse('api:v1:instance:admin-settings-list') + url = reverse("api:v1:instance:admin-settings-list") response = logged_in_api_client.get(url) assert response.status_code == 403 -def test_admin_settings_correct_permission( - db, logged_in_api_client, preferences): +def test_admin_settings_correct_permission(db, logged_in_api_client, preferences): user = logged_in_api_client.user user.permission_settings = True user.save() - url = reverse('api:v1:instance:admin-settings-list') + url = reverse("api:v1:instance:admin-settings-list") response = logged_in_api_client.get(url) assert response.status_code == 200 diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 45167722c..893cfd86e 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -2,7 +2,7 @@ from funkwhale_api.manage import serializers def test_manage_track_file_action_delete(factories): - tfs = factories['music.TrackFile'](size=5) + tfs = factories["music.TrackFile"](size=5) s = serializers.ManageTrackFileActionSerializer(queryset=None) s.handle_delete(tfs.__class__.objects.all()) diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index db2e0980a..e2bfbf3a8 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -1,26 +1,25 @@ import pytest - from django.urls import reverse -from funkwhale_api.manage import serializers -from funkwhale_api.manage import views +from funkwhale_api.manage import serializers, views -@pytest.mark.parametrize('view,permissions,operator', [ - (views.ManageTrackFileViewSet, ['library'], 'and'), -]) +@pytest.mark.parametrize( + "view,permissions,operator", [(views.ManageTrackFileViewSet, ["library"], "and")] +) def test_permissions(assert_user_permission, view, permissions, operator): assert_user_permission(view, permissions, operator) def test_track_file_view(factories, superuser_api_client): - tfs = factories['music.TrackFile'].create_batch(size=5) - qs = tfs[0].__class__.objects.order_by('-creation_date') - url = reverse('api:v1:manage:library:track-files-list') + tfs = factories["music.TrackFile"].create_batch(size=5) + qs = tfs[0].__class__.objects.order_by("-creation_date") + url = reverse("api:v1:manage:library:track-files-list") - response = superuser_api_client.get(url, {'sort': '-creation_date'}) + response = superuser_api_client.get(url, {"sort": "-creation_date"}) expected = serializers.ManageTrackFileSerializer( - qs, many=True, context={'request': response.wsgi_request}).data + qs, many=True, context={"request": response.wsgi_request} + ).data - assert response.data['count'] == len(tfs) - assert response.data['results'] == expected + assert response.data["count"] == len(tfs) + assert response.data["results"] == expected diff --git a/api/tests/music/conftest.py b/api/tests/music/conftest.py index 4eea8effe..0cb6f4778 100644 --- a/api/tests/music/conftest.py +++ b/api/tests/music/conftest.py @@ -1,66 +1,75 @@ import pytest +_artists = {"search": {}, "get": {}} -_artists = {'search': {}, 'get': {}} - -_artists['search']['adhesive_wombat'] = { - 'artist-list': [ +_artists["search"]["adhesive_wombat"] = { + "artist-list": [ { - 'type': 'Person', - 'ext:score': '100', - 'id': '62c3befb-6366-4585-b256-809472333801', - 'disambiguation': 'George Shaw', - 'gender': 'male', - 'area': {'sort-name': 'Raleigh', 'id': '3f8828b9-ba93-4604-9b92-1f616fa1abd1', 'name': 'Raleigh'}, - 'sort-name': 'Wombat, Adhesive', - 'life-span': {'ended': 'false'}, - 'name': 'Adhesive Wombat' + "type": "Person", + "ext:score": "100", + "id": "62c3befb-6366-4585-b256-809472333801", + "disambiguation": "George Shaw", + "gender": "male", + "area": { + "sort-name": "Raleigh", + "id": "3f8828b9-ba93-4604-9b92-1f616fa1abd1", + "name": "Raleigh", + }, + "sort-name": "Wombat, Adhesive", + "life-span": {"ended": "false"}, + "name": "Adhesive Wombat", }, { - 'country': 'SE', - 'type': 'Group', - 'ext:score': '42', - 'id': '61b34e69-7573-4208-bc89-7061bca5a8fc', - 'area': {'sort-name': 'Sweden', 'id': '23d10872-f5ae-3f0c-bf55-332788a16ecb', 'name': 'Sweden'}, - 'sort-name': 'Adhesive', - 'life-span': {'end': '2002-07-12', 'begin': '1994', 'ended': 'true'}, - 'name': 'Adhesive', - 'begin-area': { - 'sort-name': 'Katrineholm', - 'id': '02390d96-b5a3-4282-a38f-e64a95d08b7f', - 'name': 'Katrineholm' + "country": "SE", + "type": "Group", + "ext:score": "42", + "id": "61b34e69-7573-4208-bc89-7061bca5a8fc", + "area": { + "sort-name": "Sweden", + "id": "23d10872-f5ae-3f0c-bf55-332788a16ecb", + "name": "Sweden", + }, + "sort-name": "Adhesive", + "life-span": {"end": "2002-07-12", "begin": "1994", "ended": "true"}, + "name": "Adhesive", + "begin-area": { + "sort-name": "Katrineholm", + "id": "02390d96-b5a3-4282-a38f-e64a95d08b7f", + "name": "Katrineholm", }, }, ] } -_artists['get']['adhesive_wombat'] = {'artist': _artists['search']['adhesive_wombat']['artist-list'][0]} +_artists["get"]["adhesive_wombat"] = { + "artist": _artists["search"]["adhesive_wombat"]["artist-list"][0] +} -_artists['get']['soad'] = { - 'artist': { - 'country': 'US', - 'isni-list': ['0000000121055332'], - 'type': 'Group', - 'area': { - 'iso-3166-1-code-list': ['US'], - 'sort-name': 'United States', - 'id': '489ce91b-6658-3307-9877-795b68554c98', - 'name': 'United States' +_artists["get"]["soad"] = { + "artist": { + "country": "US", + "isni-list": ["0000000121055332"], + "type": "Group", + "area": { + "iso-3166-1-code-list": ["US"], + "sort-name": "United States", + "id": "489ce91b-6658-3307-9877-795b68554c98", + "name": "United States", }, - 'begin-area': { - 'sort-name': 'Glendale', - 'id': '6db2e45d-d7f3-43da-ac0b-7ba5ca627373', - 'name': 'Glendale' + "begin-area": { + "sort-name": "Glendale", + "id": "6db2e45d-d7f3-43da-ac0b-7ba5ca627373", + "name": "Glendale", }, - 'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6', - 'life-span': {'begin': '1994'}, - 'sort-name': 'System of a Down', - 'name': 'System of a Down' + "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6", + "life-span": {"begin": "1994"}, + "sort-name": "System of a Down", + "name": "System of a Down", } } -_albums = {'search': {}, 'get': {}, 'get_with_includes': {}} -_albums['search']['hypnotize'] = { - 'release-list': [ +_albums = {"search": {}, "get": {}, "get_with_includes": {}} +_albums["search"]["hypnotize"] = { + "release-list": [ { "artist-credit": [ { @@ -69,22 +78,22 @@ _albums['search']['hypnotize'] = { { "alias": "SoaD", "sort-name": "SoaD", - "type": "Search hint" + "type": "Search hint", }, { "alias": "S.O.A.D.", "sort-name": "S.O.A.D.", - "type": "Search hint" + "type": "Search hint", }, { "alias": "System Of Down", "sort-name": "System Of Down", - "type": "Search hint" - } + "type": "Search hint", + }, ], "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6", "name": "System of a Down", - "sort-name": "System of a Down" + "sort-name": "System of a Down", } } ], @@ -99,16 +108,16 @@ _albums['search']['hypnotize'] = { "catalog-number": "8-2796-93871-2", "label": { "id": "f5be9cfe-e1af-405c-a074-caeaed6797c0", - "name": "American Recordings" - } + "name": "American Recordings", + }, }, { "catalog-number": "D162990", "label": { "id": "9a7d39a4-a887-40f3-a645-a9a136d1f13f", - "name": "BMG Direct Marketing, Inc." - } - } + "name": "BMG Direct Marketing, Inc.", + }, + }, ], "medium-count": 1, "medium-list": [ @@ -117,7 +126,7 @@ _albums['search']['hypnotize'] = { "disc-list": [], "format": "CD", "track-count": 12, - "track-list": [] + "track-list": [], } ], "medium-track-count": 12, @@ -126,26 +135,21 @@ _albums['search']['hypnotize'] = { { "area": { "id": "489ce91b-6658-3307-9877-795b68554c98", - "iso-3166-1-code-list": [ - "US" - ], + "iso-3166-1-code-list": ["US"], "name": "United States", - "sort-name": "United States" + "sort-name": "United States", }, - "date": "2005" + "date": "2005", } ], "release-group": { "id": "72035143-d6ec-308b-8ee5-070b8703902a", "primary-type": "Album", - "type": "Album" + "type": "Album", }, "status": "Official", - "text-representation": { - "language": "eng", - "script": "Latn" - }, - "title": "Hypnotize" + "text-representation": {"language": "eng", "script": "Latn"}, + "title": "Hypnotize", }, { "artist-credit": [ @@ -155,22 +159,22 @@ _albums['search']['hypnotize'] = { { "alias": "SoaD", "sort-name": "SoaD", - "type": "Search hint" + "type": "Search hint", }, { "alias": "S.O.A.D.", "sort-name": "S.O.A.D.", - "type": "Search hint" + "type": "Search hint", }, { "alias": "System Of Down", "sort-name": "System Of Down", - "type": "Search hint" - } + "type": "Search hint", + }, ], "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6", "name": "System of a Down", - "sort-name": "System of a Down" + "sort-name": "System of a Down", } } ], @@ -188,7 +192,7 @@ _albums['search']['hypnotize'] = { "disc-list": [], "format": "Vinyl", "track-count": 12, - "track-list": [] + "track-list": [], } ], "medium-track-count": 12, @@ -196,167 +200,233 @@ _albums['search']['hypnotize'] = { { "area": { "id": "489ce91b-6658-3307-9877-795b68554c98", - "iso-3166-1-code-list": [ - "US" - ], + "iso-3166-1-code-list": ["US"], "name": "United States", - "sort-name": "United States" + "sort-name": "United States", }, - "date": "2005-12-20" + "date": "2005-12-20", } ], "release-group": { "id": "72035143-d6ec-308b-8ee5-070b8703902a", "primary-type": "Album", - "type": "Album" + "type": "Album", }, "status": "Official", - "text-representation": { - "language": "eng", - "script": "Latn" - }, - "title": "Hypnotize" + "text-representation": {"language": "eng", "script": "Latn"}, + "title": "Hypnotize", }, ] } -_albums['get']['hypnotize'] = {'release': _albums['search']['hypnotize']['release-list'][0]} -_albums['get_with_includes']['hypnotize'] = { - 'release': { - 'artist-credit': [ - {'artist': {'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6', - 'name': 'System of a Down', - 'sort-name': 'System of a Down'}}], - 'artist-credit-phrase': 'System of a Down', - 'barcode': '', - 'country': 'US', - 'cover-art-archive': {'artwork': 'true', - 'back': 'false', - 'count': '1', - 'front': 'true'}, - 'date': '2005', - 'id': '47ae093f-1607-49a3-be11-a15d335ccc94', - 'medium-count': 1, - 'medium-list': [{'format': 'CD', - 'position': '1', - 'track-count': 12, - 'track-list': [{'id': '59f5cf9a-75b2-3aa3-abda-6807a87107b3', - 'length': '186000', - 'number': '1', - 'position': '1', - 'recording': {'id': '76d03fc5-758c-48d0-a354-a67de086cc68', - 'length': '186000', - 'title': 'Attack'}, - 'track_or_recording_length': '186000'}, - {'id': '3aaa28c1-12b1-3c2a-b90a-82e09e355608', - 'length': '239000', - 'number': '2', - 'position': '2', - 'recording': {'id': '327543b0-9193-48c5-83c9-01c7b36c8c0a', - 'length': '239000', - 'title': 'Dreaming'}, - 'track_or_recording_length': '239000'}, - {'id': 'a34fef19-e637-3436-b7eb-276ff2814d6f', - 'length': '147000', - 'number': '3', - 'position': '3', - 'recording': {'id': '6e27866c-07a1-425d-bb4f-9d9e728db344', - 'length': '147000', - 'title': 'Kill Rock ’n Roll'}, - 'track_or_recording_length': '147000'}, - {'id': '72a4e5c0-c150-3ba1-9ceb-3ab82648af25', - 'length': '189000', - 'number': '4', - 'position': '4', - 'recording': {'id': '7ff8a67d-c8e2-4b3a-a045-7ad3561d0605', - 'length': '189000', - 'title': 'Hypnotize'}, - 'track_or_recording_length': '189000'}, - {'id': 'a748fa6e-b3b7-3b22-89fb-a038ec92ac32', - 'length': '178000', - 'number': '5', - 'position': '5', - 'recording': {'id': '19b6eb6a-0e76-4ef7-b63f-959339dbd5d2', - 'length': '178000', - 'title': 'Stealing Society'}, - 'track_or_recording_length': '178000'}, - {'id': '5c5a8d4e-e21a-317e-a719-6e2dbdefa5d2', - 'length': '216000', - 'number': '6', - 'position': '6', - 'recording': {'id': 'c3c2afe1-ee9a-47cb-b3c6-ff8100bc19d5', - 'length': '216000', - 'title': 'Tentative'}, - 'track_or_recording_length': '216000'}, - {'id': '265718ba-787f-3193-947b-3b6fa69ffe96', - 'length': '175000', - 'number': '7', - 'position': '7', - 'recording': {'id': '96f804e1-f600-4faa-95a6-ce597e7db120', - 'length': '175000', - 'title': 'U‐Fig'}, - 'title': 'U-Fig', - 'track_or_recording_length': '175000'}, - {'id': 'cdcf8572-3060-31ca-a72c-1ded81ca1f7a', - 'length': '328000', - 'number': '8', - 'position': '8', - 'recording': {'id': '26ba38f0-b26b-48b7-8e77-226b22a55f79', - 'length': '328000', - 'title': 'Holy Mountains'}, - 'track_or_recording_length': '328000'}, - {'id': 'f9f00cb0-5635-3217-a2a0-bd61917eb0df', - 'length': '171000', - 'number': '9', - 'position': '9', - 'recording': {'id': '039f3379-3a69-4e75-a882-df1c4e1608aa', - 'length': '171000', - 'title': 'Vicinity of Obscenity'}, - 'track_or_recording_length': '171000'}, - {'id': 'cdd45914-6741-353e-bbb5-d281048ff24f', - 'length': '164000', - 'number': '10', - 'position': '10', - 'recording': {'id': 'c24d541a-a9a8-4a22-84c6-5e6419459cf8', - 'length': '164000', - 'title': 'She’s Like Heroin'}, - 'track_or_recording_length': '164000'}, - {'id': 'cfcf12ac-6831-3dd6-a2eb-9d0bfeee3f6d', - 'length': '167000', - 'number': '11', - 'position': '11', - 'recording': {'id': '0aff4799-849f-4f83-84f4-22cabbba2378', - 'length': '167000', - 'title': 'Lonely Day'}, - 'track_or_recording_length': '167000'}, - {'id': '7e38bb38-ff62-3e41-a670-b7d77f578a1f', - 'length': '220000', - 'number': '12', - 'position': '12', - 'recording': {'id': 'e1b4d90f-2f44-4fe6-a826-362d4e3d9b88', - 'length': '220000', - 'title': 'Soldier Side'}, - 'track_or_recording_length': '220000'}]}], - 'packaging': 'Digipak', - 'quality': 'normal', - 'release-event-count': 1, - 'release-event-list': [{'area': {'id': '489ce91b-6658-3307-9877-795b68554c98', - 'iso-3166-1-code-list': ['US'], - 'name': 'United States', - 'sort-name': 'United States'}, - 'date': '2005'}], - 'status': 'Official', - 'text-representation': {'language': 'eng', 'script': 'Latn'}, - 'title': 'Hypnotize'}} +_albums["get"]["hypnotize"] = { + "release": _albums["search"]["hypnotize"]["release-list"][0] +} +_albums["get_with_includes"]["hypnotize"] = { + "release": { + "artist-credit": [ + { + "artist": { + "id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6", + "name": "System of a Down", + "sort-name": "System of a Down", + } + } + ], + "artist-credit-phrase": "System of a Down", + "barcode": "", + "country": "US", + "cover-art-archive": { + "artwork": "true", + "back": "false", + "count": "1", + "front": "true", + }, + "date": "2005", + "id": "47ae093f-1607-49a3-be11-a15d335ccc94", + "medium-count": 1, + "medium-list": [ + { + "format": "CD", + "position": "1", + "track-count": 12, + "track-list": [ + { + "id": "59f5cf9a-75b2-3aa3-abda-6807a87107b3", + "length": "186000", + "number": "1", + "position": "1", + "recording": { + "id": "76d03fc5-758c-48d0-a354-a67de086cc68", + "length": "186000", + "title": "Attack", + }, + "track_or_recording_length": "186000", + }, + { + "id": "3aaa28c1-12b1-3c2a-b90a-82e09e355608", + "length": "239000", + "number": "2", + "position": "2", + "recording": { + "id": "327543b0-9193-48c5-83c9-01c7b36c8c0a", + "length": "239000", + "title": "Dreaming", + }, + "track_or_recording_length": "239000", + }, + { + "id": "a34fef19-e637-3436-b7eb-276ff2814d6f", + "length": "147000", + "number": "3", + "position": "3", + "recording": { + "id": "6e27866c-07a1-425d-bb4f-9d9e728db344", + "length": "147000", + "title": "Kill Rock ’n Roll", + }, + "track_or_recording_length": "147000", + }, + { + "id": "72a4e5c0-c150-3ba1-9ceb-3ab82648af25", + "length": "189000", + "number": "4", + "position": "4", + "recording": { + "id": "7ff8a67d-c8e2-4b3a-a045-7ad3561d0605", + "length": "189000", + "title": "Hypnotize", + }, + "track_or_recording_length": "189000", + }, + { + "id": "a748fa6e-b3b7-3b22-89fb-a038ec92ac32", + "length": "178000", + "number": "5", + "position": "5", + "recording": { + "id": "19b6eb6a-0e76-4ef7-b63f-959339dbd5d2", + "length": "178000", + "title": "Stealing Society", + }, + "track_or_recording_length": "178000", + }, + { + "id": "5c5a8d4e-e21a-317e-a719-6e2dbdefa5d2", + "length": "216000", + "number": "6", + "position": "6", + "recording": { + "id": "c3c2afe1-ee9a-47cb-b3c6-ff8100bc19d5", + "length": "216000", + "title": "Tentative", + }, + "track_or_recording_length": "216000", + }, + { + "id": "265718ba-787f-3193-947b-3b6fa69ffe96", + "length": "175000", + "number": "7", + "position": "7", + "recording": { + "id": "96f804e1-f600-4faa-95a6-ce597e7db120", + "length": "175000", + "title": "U‐Fig", + }, + "title": "U-Fig", + "track_or_recording_length": "175000", + }, + { + "id": "cdcf8572-3060-31ca-a72c-1ded81ca1f7a", + "length": "328000", + "number": "8", + "position": "8", + "recording": { + "id": "26ba38f0-b26b-48b7-8e77-226b22a55f79", + "length": "328000", + "title": "Holy Mountains", + }, + "track_or_recording_length": "328000", + }, + { + "id": "f9f00cb0-5635-3217-a2a0-bd61917eb0df", + "length": "171000", + "number": "9", + "position": "9", + "recording": { + "id": "039f3379-3a69-4e75-a882-df1c4e1608aa", + "length": "171000", + "title": "Vicinity of Obscenity", + }, + "track_or_recording_length": "171000", + }, + { + "id": "cdd45914-6741-353e-bbb5-d281048ff24f", + "length": "164000", + "number": "10", + "position": "10", + "recording": { + "id": "c24d541a-a9a8-4a22-84c6-5e6419459cf8", + "length": "164000", + "title": "She’s Like Heroin", + }, + "track_or_recording_length": "164000", + }, + { + "id": "cfcf12ac-6831-3dd6-a2eb-9d0bfeee3f6d", + "length": "167000", + "number": "11", + "position": "11", + "recording": { + "id": "0aff4799-849f-4f83-84f4-22cabbba2378", + "length": "167000", + "title": "Lonely Day", + }, + "track_or_recording_length": "167000", + }, + { + "id": "7e38bb38-ff62-3e41-a670-b7d77f578a1f", + "length": "220000", + "number": "12", + "position": "12", + "recording": { + "id": "e1b4d90f-2f44-4fe6-a826-362d4e3d9b88", + "length": "220000", + "title": "Soldier Side", + }, + "track_or_recording_length": "220000", + }, + ], + } + ], + "packaging": "Digipak", + "quality": "normal", + "release-event-count": 1, + "release-event-list": [ + { + "area": { + "id": "489ce91b-6658-3307-9877-795b68554c98", + "iso-3166-1-code-list": ["US"], + "name": "United States", + "sort-name": "United States", + }, + "date": "2005", + } + ], + "status": "Official", + "text-representation": {"language": "eng", "script": "Latn"}, + "title": "Hypnotize", + } +} -_albums['get']['marsupial'] = { - 'release': { +_albums["get"]["marsupial"] = { + "release": { "artist-credit": [ { "artist": { "disambiguation": "George Shaw", "id": "62c3befb-6366-4585-b256-809472333801", "name": "Adhesive Wombat", - "sort-name": "Wombat, Adhesive" + "sort-name": "Wombat, Adhesive", } } ], @@ -366,7 +436,7 @@ _albums['get']['marsupial'] = { "artwork": "true", "back": "false", "count": "1", - "front": "true" + "front": "true", }, "date": "2013-06-05", "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e", @@ -377,28 +447,23 @@ _albums['get']['marsupial'] = { { "area": { "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", - "iso-3166-1-code-list": [ - "XW" - ], + "iso-3166-1-code-list": ["XW"], "name": "[Worldwide]", - "sort-name": "[Worldwide]" + "sort-name": "[Worldwide]", }, - "date": "2013-06-05" + "date": "2013-06-05", } ], "status": "Official", - "text-representation": { - "language": "eng", - "script": "Latn" - }, - "title": "Marsupial Madness" + "text-representation": {"language": "eng", "script": "Latn"}, + "title": "Marsupial Madness", } } -_tracks = {'search': {}, 'get': {}} +_tracks = {"search": {}, "get": {}} -_tracks['search']['8bitadventures'] = { - 'recording-list': [ +_tracks["search"]["8bitadventures"] = { + "recording-list": [ { "artist-credit": [ { @@ -406,7 +471,7 @@ _tracks['search']['8bitadventures'] = { "disambiguation": "George Shaw", "id": "62c3befb-6366-4585-b256-809472333801", "name": "Adhesive Wombat", - "sort-name": "Wombat, Adhesive" + "sort-name": "Wombat, Adhesive", } } ], @@ -430,9 +495,9 @@ _tracks['search']['8bitadventures'] = { "length": "271000", "number": "1", "title": "8-Bit Adventure", - "track_or_recording_length": "271000" + "track_or_recording_length": "271000", } - ] + ], } ], "medium-track-count": 11, @@ -440,70 +505,85 @@ _tracks['search']['8bitadventures'] = { { "area": { "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", - "iso-3166-1-code-list": [ - "XW" - ], + "iso-3166-1-code-list": ["XW"], "name": "[Worldwide]", - "sort-name": "[Worldwide]" + "sort-name": "[Worldwide]", }, - "date": "2013-06-05" + "date": "2013-06-05", } ], "release-group": { "id": "447b4979-2178-405c-bfe6-46bf0b09e6c7", "primary-type": "Album", - "type": "Album" + "type": "Album", }, "status": "Official", - "title": "Marsupial Madness" + "title": "Marsupial Madness", } ], "title": "8-Bit Adventure", "tag-list": [ - { - "count": "2", - "name": "techno" - }, - { - "count": "2", - "name": "good-music" - }, + {"count": "2", "name": "techno"}, + {"count": "2", "name": "good-music"}, ], - }, + } ] } -_tracks['get']['8bitadventures'] = {'recording': _tracks['search']['8bitadventures']['recording-list'][0]} -_tracks['get']['chop_suey'] = { - 'recording': { - 'id': '46c7368a-013a-47b6-97cc-e55e7ab25213', - 'length': '210240', - 'title': 'Chop Suey!', - 'work-relation-list': [{'target': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5', - 'type': 'performance', - 'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0', - 'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5', - 'language': 'eng', - 'title': 'Chop Suey!'}}]}} +_tracks["get"]["8bitadventures"] = { + "recording": _tracks["search"]["8bitadventures"]["recording-list"][0] +} +_tracks["get"]["chop_suey"] = { + "recording": { + "id": "46c7368a-013a-47b6-97cc-e55e7ab25213", + "length": "210240", + "title": "Chop Suey!", + "work-relation-list": [ + { + "target": "e2ecabc4-1b9d-30b2-8f30-3596ec423dc5", + "type": "performance", + "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", + "work": { + "id": "e2ecabc4-1b9d-30b2-8f30-3596ec423dc5", + "language": "eng", + "title": "Chop Suey!", + }, + } + ], + } +} -_works = {'search': {}, 'get': {}} -_works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5', - 'language': 'eng', - 'recording-relation-list': [{'direction': 'backward', - 'recording': {'disambiguation': 'edit', - 'id': '07ca77cf-f513-4e9c-b190-d7e24bbad448', - 'length': '170893', - 'title': 'Chop Suey!'}, - 'target': '07ca77cf-f513-4e9c-b190-d7e24bbad448', - 'type': 'performance', - 'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0'}, - ], - 'title': 'Chop Suey!', - 'type': 'Song', - 'url-relation-list': [{'direction': 'backward', - 'target': 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!', - 'type': 'lyrics', - 'type-id': 'e38e65aa-75e0-42ba-ace0-072aeb91a538'}]}} +_works = {"search": {}, "get": {}} +_works["get"]["chop_suey"] = { + "work": { + "id": "e2ecabc4-1b9d-30b2-8f30-3596ec423dc5", + "language": "eng", + "recording-relation-list": [ + { + "direction": "backward", + "recording": { + "disambiguation": "edit", + "id": "07ca77cf-f513-4e9c-b190-d7e24bbad448", + "length": "170893", + "title": "Chop Suey!", + }, + "target": "07ca77cf-f513-4e9c-b190-d7e24bbad448", + "type": "performance", + "type-id": "a3005666-a872-32c3-ad06-98af558e99b0", + } + ], + "title": "Chop Suey!", + "type": "Song", + "url-relation-list": [ + { + "direction": "backward", + "target": "http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!", + "type": "lyrics", + "type-id": "e38e65aa-75e0-42ba-ace0-072aeb91a538", + } + ], + } +} @pytest.fixture() @@ -537,7 +617,7 @@ def lyricswiki_content(): - + @@ -570,4 +650,4 @@ def binary_cover(): """ Return an album cover image in form of a binary string """ - return b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xed\x08\xaePhotoshop 3.0\x008BIM\x03\xe9\x00\x00\x00\x00\x00x\x00\x03\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\xff\xe1\xff\xe2\x02\xf9\x02F\x03G\x05(\x03\xfc\x00\x02\x00\x00\x00H\x00H\x00\x00\x00\x00\x02\xd8\x02(\x00\x01\x00\x00\x00d\x00\x00\x00\x01\x00\x03\x03\x03\x00\x00\x00\x01\'\x0f\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00`\x08\x00\x19\x01\x90\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x008BIM\x03\xed\x00\x00\x00\x00\x00\x10\x00H\x00\x00\x00\x01\x00\x01\x00H\x00\x00\x00\x01\x00\x018BIM\x03\xf3\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x008BIM\x04\n\x00\x00\x00\x00\x00\x01\x00\x008BIM\'\x10\x00\x00\x00\x00\x00\n\x00\x01\x00\x00\x00\x00\x00\x00\x00\x028BIM\x03\xf5\x00\x00\x00\x00\x00H\x00/ff\x00\x01\x00lff\x00\x06\x00\x00\x00\x00\x00\x01\x00/ff\x00\x01\x00\xa1\x99\x9a\x00\x06\x00\x00\x00\x00\x00\x01\x002\x00\x00\x00\x01\x00Z\x00\x00\x00\x06\x00\x00\x00\x00\x00\x01\x005\x00\x00\x00\x01\x00-\x00\x00\x00\x06\x00\x00\x00\x00\x00\x018BIM\x03\xf8\x00\x00\x00\x00\x00p\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x03\xe8\x00\x008BIM\x04\x00\x00\x00\x00\x00\x00\x02\x00\x018BIM\x04\x02\x00\x00\x00\x00\x00\x04\x00\x00\x00\x008BIM\x04\x08\x00\x00\x00\x00\x00\x10\x00\x00\x00\x01\x00\x00\x02@\x00\x00\x02@\x00\x00\x00\x008BIM\x04\t\x00\x00\x00\x00\x06\x9b\x00\x00\x00\x01\x00\x00\x00\x80\x00\x00\x00\x80\x00\x00\x01\x80\x00\x00\xc0\x00\x00\x00\x06\x7f\x00\x18\x00\x01\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x02\x01\x00H\x00H\x00\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x80\x00\x00\x00\x01\xff\xdb\x00\x84\x00\x0c\x08\x08\x08\t\x08\x0c\t\t\x0c\x11\x0b\n\x0b\x11\x15\x0f\x0c\x0c\x0f\x15\x18\x13\x13\x15\x13\x13\x18\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\r\x0b\x0b\r\x0e\r\x10\x0e\x0e\x10\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\x80\x00\x80\x03\x01"\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x08\xff\xc4\x01?\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x01\x02\x04\x05\x06\x07\x08\t\n\x0b\x01\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x01\x04\x01\x03\x02\x04\x02\x05\x07\x06\x08\x05\x03\x0c3\x01\x00\x02\x11\x03\x04!\x121\x05AQa\x13"q\x812\x06\x14\x91\xa1\xb1B#$\x15R\xc1b34r\x82\xd1C\x07%\x92S\xf0\xe1\xf1cs5\x16\xa2\xb2\x83&D\x93TdE\xc2\xa3t6\x17\xd2U\xe2e\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\'\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf7\x11\x00\x02\x02\x01\x02\x04\x04\x03\x04\x05\x06\x07\x07\x06\x055\x01\x00\x02\x11\x03!1\x12\x04AQaq"\x13\x052\x81\x91\x14\xa1\xb1B#\xc1R\xd1\xf03$b\xe1r\x82\x92CS\x15cs4\xf1%\x06\x16\xa2\xb2\x83\x07&5\xc2\xd2D\x93T\xa3\x17dEU6te\xe2\xf2\xb3\x84\xc3\xd3u\xe3\xf3F\x94\xa4\x85\xb4\x95\xc4\xd4\xe4\xf4\xa5\xb5\xc5\xd5\xe5\xf5Vfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6\'7GWgw\x87\x97\xa7\xb7\xc7\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd0\xf5T\x92I%)$\x92IJI%\xe7\xff\x00Z\x7f\xc6\xbf\xfc\xde\xeb\xb9]\x1f\xf6_\xda~\xcd\xe9\xfe\x9b\xed\x1e\x9e\xefR\xba\xef\xfeo\xec\xf6\xed\xdb\xea\xec\xfeq%>\x80\x92\xf2\xaf\xfc}?\xf3I\xff\x00\xb3_\xfb\xe8\x97\xfe>\x9f\xf9\xa4\xff\x00\xd9\xaf\xfd\xf4IO\xaa\xa4\xbc\xab\xff\x00\x1fO\xfc\xd2\x7f\xec\xd7\xfe\xfa%\xff\x00\x8f\xa7\xfei?\xf6k\xff\x00}\x12S\xea\xa9.+\xeaW\xf8\xc8\xff\x00\x9d}V\xde\x9d\xfb;\xec~\x96;\xb2=O[\xd5\x9d\xaf\xaa\xad\x9b=\n\x7f\xd3}-\xeb\xb5IJI$\x92R\x92I$\x94\xff\x00\xff\xd1\xf5T\x92I%)$\x97\x9f\xff\x00\x8d\x7f\xad=w\xea\xf7\xec\xbf\xd8\xf9_f\xfbO\xda=o\xd1\xd7f\xefO\xec\xfe\x9f\xf3\xf5\xdb\xb7o\xabg\xd0IO\xa0/\x9f\xff\x00\xc6\x97\xfe.\xfa\x9f\xfdc\xff\x00m\xf1\xd2\xff\x00\xc7K\xeb\xdf\xfeY\xff\x00\xe0\x18\xff\x00\xfb\xce\xb9\xfe\xa9\xd53\xfa\xbe}\xbdG\xa8\xdb\xeb\xe5\xdf\xb7\xd4\xb3kY;\x1a\xda\x99\xec\xa9\xac\xaf\xf9\xb63\xf3\x12SU$\x92IJI$\x92S\xdf\xff\x00\x89O\xfcUe\x7f\xe1\x0b?\xf3\xf6*\xf6\xb5\xf3/D\xeb\xfd[\xa0\xe5?3\xa4\xdf\xf6l\x8b+59\xfb\x18\xf9a-\xb1\xcd\xdb{-g\xd3\xa9\x8bk\xff\x00\x1d/\xaf\x7f\xf9g\xff\x00\x80c\xff\x00\xef:J~\x80Iq\xff\x00\xe2\xbf\xaf\xf5n\xbd\xd023:\xb5\xff\x00i\xc8\xaf-\xf55\xfb\x18\xc8`\xae\x8b\x1a\xdd\xb42\xa6};^\xbb\x04\x94\xa4\x92I%?\xff\xd2\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd3\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd4\xf5T\x92I%)q_\xe3#\xeaWU\xfa\xd7\xfb;\xf6u\xb8\xf5}\x8f\xd6\xf5>\xd0\xe7\xb6}_Cf\xcfJ\xab\xbf\xd0\xbfr\xedRIO\x8a\x7f\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*\xe4:\xff\x00D\xca\xe8=Z\xfe\x93\x98\xfa\xec\xc8\xc6\xd9\xbd\xd5\x12Xw\xb1\x97\xb7k\xacmO\xfa\x16\xfe\xe2\xfai|\xff\x00\xfe4\xbf\xf1w\xd4\xff\x00\xeb\x1f\xfbo\x8e\x92\x9eU$\x92IJI$\x92S\xb1\xf5_\xea\xbfP\xfa\xd1\xd4,\xc0\xc0\xb2\x9a\xad\xaa\x93{\x9dys[\xb5\xae\xae\xa2\x01\xaa\xbb\x9d\xbfu\xcd\xfc\xd5\xd3\xff\x00\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*_\xe2S\xff\x00\x15Y_\xf8B\xcf\xfc\xfd\x8a\xbd\xad%<\xbf\xf8\xbc\xfa\xaf\xd4>\xab\xf4[\xb03\xec\xa6\xdbm\xc9u\xedu\x05\xcen\xd7WM@\x13mt\xbb~\xea]\xf9\xab\xa8I$\x94\xa4\x92I%?\xff\xd5\xf5T\x92I%)$\x92IJ\\\x7f_\xff\x00\x15\xfd\x03\xafuk\xfa\xb6fF]y\x19;7\xb6\xa7\xd6\x1861\x947kl\xa2\xd7\xfd\n\xbf}v\t$\xa7\xcf\xff\x00\xf1\x94\xfa\xab\xff\x00r\xb3\xff\x00\xed\xca\x7f\xf7\x95/\xfce>\xaa\xff\x00\xdc\xac\xff\x00\xfbr\x9f\xfd\xe5^\x80\x92J|\xff\x00\xff\x00\x19O\xaa\xbf\xf7+?\xfe\xdc\xa7\xff\x00yR\xff\x00\xc6S\xea\xaf\xfd\xca\xcf\xff\x00\xb7)\xff\x00\xdeU\xe8\t$\xa7\x97\xfa\xaf\xfe/:/\xd5~\xa1f~\x05\xd96\xdbm&\x876\xf7V\xe6\xeds\xab\xb4\x90*\xa6\x97o\xddK\x7f9u\t$\x92\x94\x92I$\xa5$\x92I)\xff\xd6\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd9\x008BIM\x04\x06\x00\x00\x00\x00\x00\x07\x00\x03\x00\x00\x00\x01\x01\x00\xff\xfe\x00\'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x00\x00\x00\x00\x01\xff\xdb\x00\x84\x00\n\x07\x07\x07\x08\x07\n\x08\x08\n\x0f\n\x08\n\x0f\x12\r\n\n\r\x12\x14\x10\x10\x12\x10\x10\x14\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\x0b\x0c\x0c\x15\x13\x15"\x18\x18"\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\t\x00\t\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x02\xff\xc4\x01\xa2\x00\x00\x00\x07\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x04\x05\x03\x02\x06\x01\x00\x07\x08\t\n\x0b\x01\x00\x02\x02\x03\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x02\x01\x03\x03\x02\x04\x02\x06\x07\x03\x04\x02\x06\x02s\x01\x02\x03\x11\x04\x00\x05!\x121AQ\x06\x13a"q\x81\x142\x91\xa1\x07\x15\xb1B#\xc1R\xd1\xe13\x16b\xf0$r\x82\xf1%C4S\x92\xa2\xb2cs\xc25D\'\x93\xa3\xb36\x17Tdt\xc3\xd2\xe2\x08&\x83\t\n\x18\x19\x84\x94EF\xa4\xb4V\xd3U(\x1a\xf2\xe3\xf3\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5fv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf8)9IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\x11\x00\x02\x02\x01\x02\x03\x05\x05\x04\x05\x06\x04\x08\x03\x03m\x01\x00\x02\x11\x03\x04!\x121A\x05Q\x13a"\x06q\x81\x912\xa1\xb1\xf0\x14\xc1\xd1\xe1#B\x15Rbr\xf13$4C\x82\x16\x92S%\xa2c\xb2\xc2\x07s\xd25\xe2D\x83\x17T\x93\x08\t\n\x18\x19&6E\x1a\'dtU7\xf2\xa3\xb3\xc3()\xd3\xe3\xf3\x84\x94\xa4\xb4\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5FVfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf89IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\x91\xea\xfa\xbf\xe6D_\x99\x16\x96\x16\x16\x8c\xdeWf\x84;\x88U\xa1hY\x7f\xd3\'\x9e\xf3\xedCq\x0bz\xfe\x94^\xbc?\xdc\xdb\xff\x00\xa3\xcd\xeb\x7f\xa4\xaa\xf4\x80\x92\xf2\xaf\xfc}?\xf3I\xff\x00\xb3_\xfb\xe8\x97\xfe>\x9f\xf9\xa4\xff\x00\xd9\xaf\xfd\xf4IO\xaa\xa4\xbc\xab\xff\x00\x1fO\xfc\xd2\x7f\xec\xd7\xfe\xfa%\xff\x00\x8f\xa7\xfei?\xf6k\xff\x00}\x12S\xea\xa9.+\xeaW\xf8\xc8\xff\x00\x9d}V\xde\x9d\xfb;\xec~\x96;\xb2=O[\xd5\x9d\xaf\xaa\xad\x9b=\n\x7f\xd3}-\xeb\xb5IJI$\x92R\x92I$\x94\xff\x00\xff\xd1\xf5T\x92I%)$\x97\x9f\xff\x00\x8d\x7f\xad=w\xea\xf7\xec\xbf\xd8\xf9_f\xfbO\xda=o\xd1\xd7f\xefO\xec\xfe\x9f\xf3\xf5\xdb\xb7o\xabg\xd0IO\xa0/\x9f\xff\x00\xc6\x97\xfe.\xfa\x9f\xfdc\xff\x00m\xf1\xd2\xff\x00\xc7K\xeb\xdf\xfeY\xff\x00\xe0\x18\xff\x00\xfb\xce\xb9\xfe\xa9\xd53\xfa\xbe}\xbdG\xa8\xdb\xeb\xe5\xdf\xb7\xd4\xb3kY;\x1a\xda\x99\xec\xa9\xac\xaf\xf9\xb63\xf3\x12SU$\x92IJI$\x92S\xdf\xff\x00\x89O\xfcUe\x7f\xe1\x0b?\xf3\xf6*\xf6\xb5\xf3/D\xeb\xfd[\xa0\xe5?3\xa4\xdf\xf6l\x8b+59\xfb\x18\xf9a-\xb1\xcd\xdb{-g\xd3\xa9\x8bk\xff\x00\x1d/\xaf\x7f\xf9g\xff\x00\x80c\xff\x00\xef:J~\x80Iq\xff\x00\xe2\xbf\xaf\xf5n\xbd\xd023:\xb5\xff\x00i\xc8\xaf-\xf55\xfb\x18\xc8`\xae\x8b\x1a\xdd\xb42\xa6};^\xbb\x04\x94\xa4\x92I%?\xff\xd2\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd3\xf5T\x92I%)yW\xf8\xf4\xff\x00\xbcO\xfd\n\xff\x00\xddE\xea\xab\xca\xbf\xc7\xa7\xfd\xe2\x7f\xe8W\xfe\xea$\xa7\xca\x92I$\x94\xa4\x92I%)$\x92IJI$\x92S\xed_\xe2S\xff\x00\x12\xb9_\xf8~\xcf\xfc\xf3\x8a\xbd\x01y\xff\x00\xf8\x94\xff\x00\xc4\xaeW\xfe\x1f\xb3\xff\x00<\xe2\xaf@IJI$\x92S\xff\xd4\xf5T\x92I%)q_\xe3#\xeaWU\xfa\xd7\xfb;\xf6u\xb8\xf5}\x8f\xd6\xf5>\xd0\xe7\xb6}_Cf\xcfJ\xab\xbf\xd0\xbfr\xedRIO\x8a\x7f\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*\xe4:\xff\x00D\xca\xe8=Z\xfe\x93\x98\xfa\xec\xc8\xc6\xd9\xbd\xd5\x12Xw\xb1\x97\xb7k\xacmO\xfa\x16\xfe\xe2\xfai|\xff\x00\xfe4\xbf\xf1w\xd4\xff\x00\xeb\x1f\xfbo\x8e\x92\x9eU$\x92IJI$\x92S\xb1\xf5_\xea\xbfP\xfa\xd1\xd4,\xc0\xc0\xb2\x9a\xad\xaa\x93{\x9dys[\xb5\xae\xae\xa2\x01\xaa\xbb\x9d\xbfu\xcd\xfc\xd5\xd3\xff\x00\xe3)\xf5\xab\xfe\xe5`\x7f\xdb\x97\x7f\xef*_\xe2S\xff\x00\x15Y_\xf8B\xcf\xfc\xfd\x8a\xbd\xad%<\xbf\xf8\xbc\xfa\xaf\xd4>\xab\xf4[\xb03\xec\xa6\xdbm\xc9u\xedu\x05\xcen\xd7WM@\x13mt\xbb~\xea]\xf9\xab\xa8I$\x94\xa4\x92I%?\xff\xd5\xf5T\x92I%)$\x92IJ\\\x7f_\xff\x00\x15\xfd\x03\xafuk\xfa\xb6fF]y\x19;7\xb6\xa7\xd6\x1861\x947kl\xa2\xd7\xfd\n\xbf}v\t$\xa7\xcf\xff\x00\xf1\x94\xfa\xab\xff\x00r\xb3\xff\x00\xed\xca\x7f\xf7\x95/\xfce>\xaa\xff\x00\xdc\xac\xff\x00\xfbr\x9f\xfd\xe5^\x80\x92J|\xff\x00\xff\x00\x19O\xaa\xbf\xf7+?\xfe\xdc\xa7\xff\x00yR\xff\x00\xc6S\xea\xaf\xfd\xca\xcf\xff\x00\xb7)\xff\x00\xdeU\xe8\t$\xa7\x97\xfa\xaf\xfe/:/\xd5~\xa1f~\x05\xd96\xdbm&\x876\xf7V\xe6\xeds\xab\xb4\x90*\xa6\x97o\xddK\x7f9u\t$\x92\x94\x92I$\xa5$\x92I)\xff\xd6\xf5T\x92I%)$\x92IJI$\x92R\x92I$\x94\xa4\x92I%)$\x92IJI$\x92R\x92I$\x94\xff\x00\xff\xd9\x008BIM\x04\x06\x00\x00\x00\x00\x00\x07\x00\x03\x00\x00\x00\x01\x01\x00\xff\xfe\x00'File written by Adobe Photoshop\xa8 4.0\x00\xff\xee\x00\x0eAdobe\x00d\x00\x00\x00\x00\x01\xff\xdb\x00\x84\x00\n\x07\x07\x07\x08\x07\n\x08\x08\n\x0f\n\x08\n\x0f\x12\r\n\n\r\x12\x14\x10\x10\x12\x10\x10\x14\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x01\x0b\x0c\x0c\x15\x13\x15\"\x18\x18\"\x14\x0e\x0e\x0e\x14\x14\x0e\x0e\x0e\x0e\x14\x11\x0c\x0c\x0c\x0c\x0c\x11\x11\x0c\x0c\x0c\x0c\x0c\x0c\x11\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\x0c\xff\xc0\x00\x11\x08\x00\t\x00\t\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xdd\x00\x04\x00\x02\xff\xc4\x01\xa2\x00\x00\x00\x07\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x04\x05\x03\x02\x06\x01\x00\x07\x08\t\n\x0b\x01\x00\x02\x02\x03\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x10\x00\x02\x01\x03\x03\x02\x04\x02\x06\x07\x03\x04\x02\x06\x02s\x01\x02\x03\x11\x04\x00\x05!\x121AQ\x06\x13a\"q\x81\x142\x91\xa1\x07\x15\xb1B#\xc1R\xd1\xe13\x16b\xf0$r\x82\xf1%C4S\x92\xa2\xb2cs\xc25D'\x93\xa3\xb36\x17Tdt\xc3\xd2\xe2\x08&\x83\t\n\x18\x19\x84\x94EF\xa4\xb4V\xd3U(\x1a\xf2\xe3\xf3\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5fv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf67GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf8)9IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\x11\x00\x02\x02\x01\x02\x03\x05\x05\x04\x05\x06\x04\x08\x03\x03m\x01\x00\x02\x11\x03\x04!\x121A\x05Q\x13a\"\x06q\x81\x912\xa1\xb1\xf0\x14\xc1\xd1\xe1#B\x15Rbr\xf13$4C\x82\x16\x92S%\xa2c\xb2\xc2\x07s\xd25\xe2D\x83\x17T\x93\x08\t\n\x18\x19&6E\x1a'dtU7\xf2\xa3\xb3\xc3()\xd3\xe3\xf3\x84\x94\xa4\xb4\xc4\xd4\xe4\xf4eu\x85\x95\xa5\xb5\xc5\xd5\xe5\xf5FVfv\x86\x96\xa6\xb6\xc6\xd6\xe6\xf6GWgw\x87\x97\xa7\xb7\xc7\xd7\xe7\xf78HXhx\x88\x98\xa8\xb8\xc8\xd8\xe8\xf89IYiy\x89\x99\xa9\xb9\xc9\xd9\xe9\xf9*:JZjz\x8a\x9a\xaa\xba\xca\xda\xea\xfa\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00?\x00\x91\xea\xfa\xbf\xe6D_\x99\x16\x96\x16\x16\x8c\xdeWf\x84;\x88U\xa1hY\x7f\xd3'\x9e\xf3\xedCq\x0bz\xfe\x94^\xbc?\xdc\xdb\xff\x00\xa3\xcd\xeb\x7f\xa4\xaa\xf4 0 - assert type(cover_data['content']) == bytes - assert type(cover_data['description']) == str + cover_data = data.get_picture("cover_front") + assert cover_data["mimetype"].startswith("image/") + assert len(cover_data["content"]) > 0 + assert type(cover_data["content"]) == bytes + assert type(cover_data["description"]) == str -@pytest.mark.parametrize('field,value', [ - ('title', '999,999'), - ('artist', 'Nine Inch Nails'), - ('album', 'The Slip'), - ('date', datetime.date(2008, 5, 5)), - ('track_number', 1), - ('musicbrainz_albumid', uuid.UUID('12b57d46-a192-499e-a91f-7da66790a1c1')), - ('musicbrainz_recordingid', uuid.UUID('30f3f33e-8d0c-4e69-8539-cbd701d18f28')), - ('musicbrainz_artistid', uuid.UUID('b7ffd2af-418f-4be2-bdd1-22f8b48613da')), -]) +@pytest.mark.parametrize( + "field,value", + [ + ("title", "999,999"), + ("artist", "Nine Inch Nails"), + ("album", "The Slip"), + ("date", datetime.date(2008, 5, 5)), + ("track_number", 1), + ("musicbrainz_albumid", uuid.UUID("12b57d46-a192-499e-a91f-7da66790a1c1")), + ("musicbrainz_recordingid", uuid.UUID("30f3f33e-8d0c-4e69-8539-cbd701d18f28")), + ("musicbrainz_artistid", uuid.UUID("b7ffd2af-418f-4be2-bdd1-22f8b48613da")), + ], +) def test_can_get_metadata_from_flac_file(field, value): - path = os.path.join(DATA_DIR, 'sample.flac') + path = os.path.join(DATA_DIR, "sample.flac") data = metadata.Metadata(path) assert data.get(field) == value def test_can_get_metadata_from_flac_file_not_crash_if_empty(): - path = os.path.join(DATA_DIR, 'sample.flac') + path = os.path.join(DATA_DIR, "sample.flac") data = metadata.Metadata(path) with pytest.raises(metadata.TagNotFound): - data.get('test') + data.get("test") -@pytest.mark.parametrize('field_name', [ - 'musicbrainz_artistid', - 'musicbrainz_albumid', - 'musicbrainz_recordingid', -]) +@pytest.mark.parametrize( + "field_name", + ["musicbrainz_artistid", "musicbrainz_albumid", "musicbrainz_recordingid"], +) def test_mbid_clean_keeps_only_first(field_name): u1 = str(uuid.uuid4()) u2 = str(uuid.uuid4()) field = metadata.VALIDATION[field_name] - result = field.to_python('/'.join([u1, u2])) + result = field.to_python("/".join([u1, u2])) assert str(result) == u1 diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 0ef54eb66..df18a0909 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -1,15 +1,14 @@ import os + import pytest -from funkwhale_api.music import models -from funkwhale_api.music import importers -from funkwhale_api.music import tasks +from funkwhale_api.music import importers, models, tasks DATA_DIR = os.path.dirname(os.path.abspath(__file__)) def test_can_store_release_group_id_on_album(factories): - album = factories['music.Album']() + album = factories["music.Album"]() assert album.release_group_id is not None @@ -21,7 +20,7 @@ def test_import_album_stores_release_group(factories): "disambiguation": "George Shaw", "id": "62c3befb-6366-4585-b256-809472333801", "name": "Adhesive Wombat", - "sort-name": "Wombat, Adhesive" + "sort-name": "Wombat, Adhesive", } } ], @@ -31,137 +30,134 @@ def test_import_album_stores_release_group(factories): "id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e", "status": "Official", "title": "Marsupial Madness", - 'release-group': {'id': '447b4979-2178-405c-bfe6-46bf0b09e6c7'} + "release-group": {"id": "447b4979-2178-405c-bfe6-46bf0b09e6c7"}, } - artist = factories['music.Artist']( - mbid=album_data['artist-credit'][0]['artist']['id'] + artist = factories["music.Artist"]( + mbid=album_data["artist-credit"][0]["artist"]["id"] ) cleaned_data = models.Album.clean_musicbrainz_data(album_data) album = importers.load(models.Album, cleaned_data, album_data, import_hooks=[]) - assert album.release_group_id == album_data['release-group']['id'] + assert album.release_group_id == album_data["release-group"]["id"] assert album.artist == artist def test_import_track_from_release(factories, mocker): - album = factories['music.Album']( - mbid='430347cb-0879-3113-9fde-c75b658c298e') + album = factories["music.Album"](mbid="430347cb-0879-3113-9fde-c75b658c298e") album_data = { - 'release': { - 'id': album.mbid, - 'title': 'Daydream Nation', - 'status': 'Official', - 'medium-count': 1, - 'medium-list': [ + "release": { + "id": album.mbid, + "title": "Daydream Nation", + "status": "Official", + "medium-count": 1, + "medium-list": [ { - 'position': '1', - 'format': 'CD', - 'track-list': [ + "position": "1", + "format": "CD", + "track-list": [ { - 'id': '03baca8b-855a-3c05-8f3d-d3235287d84d', - 'position': '4', - 'number': '4', - 'length': '417973', - 'recording': { - 'id': '2109e376-132b-40ad-b993-2bb6812e19d4', - 'title': 'Teen Age Riot', - 'length': '417973'}, - 'track_or_recording_length': '417973' + "id": "03baca8b-855a-3c05-8f3d-d3235287d84d", + "position": "4", + "number": "4", + "length": "417973", + "recording": { + "id": "2109e376-132b-40ad-b993-2bb6812e19d4", + "title": "Teen Age Riot", + "length": "417973", + }, + "track_or_recording_length": "417973", } ], - 'track-count': 1 + "track-count": 1, } ], } } mocked_get = mocker.patch( - 'funkwhale_api.musicbrainz.api.releases.get', - return_value=album_data) - track_data = album_data['release']['medium-list'][0]['track-list'][0] + "funkwhale_api.musicbrainz.api.releases.get", return_value=album_data + ) + track_data = album_data["release"]["medium-list"][0]["track-list"][0] track = models.Track.get_or_create_from_release( - '430347cb-0879-3113-9fde-c75b658c298e', - track_data['recording']['id'], + "430347cb-0879-3113-9fde-c75b658c298e", track_data["recording"]["id"] )[0] - mocked_get.assert_called_once_with( - album.mbid, includes=models.Album.api_includes) - assert track.title == track_data['recording']['title'] - assert track.mbid == track_data['recording']['id'] + mocked_get.assert_called_once_with(album.mbid, includes=models.Album.api_includes) + assert track.title == track_data["recording"]["title"] + assert track.mbid == track_data["recording"]["id"] assert track.album == album assert track.artist == album.artist - assert track.position == int(track_data['position']) + assert track.position == int(track_data["position"]) + def test_import_job_is_bound_to_track_file(factories, mocker): - track = factories['music.Track']() - job = factories['music.ImportJob'](mbid=track.mbid) + track = factories["music.Track"]() + job = factories["music.ImportJob"](mbid=track.mbid) - mocker.patch('funkwhale_api.music.models.TrackFile.download_file') + mocker.patch("funkwhale_api.music.models.TrackFile.download_file") tasks.import_job_run(import_job_id=job.pk) job.refresh_from_db() assert job.track_file.track == track -@pytest.mark.parametrize('status', ['pending', 'errored', 'finished']) -def test_saving_job_updates_batch_status(status,factories, mocker): - batch = factories['music.ImportBatch']() +@pytest.mark.parametrize("status", ["pending", "errored", "finished"]) +def test_saving_job_updates_batch_status(status, factories, mocker): + batch = factories["music.ImportBatch"]() - assert batch.status == 'pending' + assert batch.status == "pending" - job = factories['music.ImportJob'](batch=batch, status=status) + factories["music.ImportJob"](batch=batch, status=status) batch.refresh_from_db() assert batch.status == status -@pytest.mark.parametrize('extention,mimetype', [ - ('ogg', 'audio/ogg'), - ('mp3', 'audio/mpeg'), -]) +@pytest.mark.parametrize( + "extention,mimetype", [("ogg", "audio/ogg"), ("mp3", "audio/mpeg")] +) def test_audio_track_mime_type(extention, mimetype, factories): - name = '.'.join(['test', extention]) + name = ".".join(["test", extention]) path = os.path.join(DATA_DIR, name) - tf = factories['music.TrackFile'](audio_file__from_path=path) + tf = factories["music.TrackFile"](audio_file__from_path=path) assert tf.mimetype == mimetype def test_track_file_file_name(factories): - name = 'test.mp3' + name = "test.mp3" path = os.path.join(DATA_DIR, name) - tf = factories['music.TrackFile'](audio_file__from_path=path) + tf = factories["music.TrackFile"](audio_file__from_path=path) - assert tf.filename == tf.track.full_name + '.mp3' + assert tf.filename == tf.track.full_name + ".mp3" def test_track_get_file_size(factories): - name = 'test.mp3' + name = "test.mp3" path = os.path.join(DATA_DIR, name) - tf = factories['music.TrackFile'](audio_file__from_path=path) + tf = factories["music.TrackFile"](audio_file__from_path=path) assert tf.get_file_size() == 297745 def test_track_get_file_size_federation(factories): - tf = factories['music.TrackFile']( - federation=True, - library_track__with_audio_file=True) + tf = factories["music.TrackFile"]( + federation=True, library_track__with_audio_file=True + ) assert tf.get_file_size() == tf.library_track.audio_file.size def test_track_get_file_size_in_place(factories): - name = 'test.mp3' + name = "test.mp3" path = os.path.join(DATA_DIR, name) - tf = factories['music.TrackFile']( - in_place=True, source='file://{}'.format(path)) + tf = factories["music.TrackFile"](in_place=True, source="file://{}".format(path)) assert tf.get_file_size() == 297745 def test_album_get_image_content(factories): - album = factories['music.Album']() - album.get_image(data={'content': b'test', 'mimetype':'image/jpeg'}) + album = factories["music.Album"]() + album.get_image(data={"content": b"test", "mimetype": "image/jpeg"}) album.refresh_from_db() - assert album.cover.read() == b'test' + assert album.cover.read() == b"test" diff --git a/api/tests/music/test_music.py b/api/tests/music/test_music.py index 4162912e4..387cebb2c 100644 --- a/api/tests/music/test_music.py +++ b/api/tests/music/test_music.py @@ -1,125 +1,142 @@ -import pytest -from funkwhale_api.music import models import datetime +import pytest + +from funkwhale_api.music import models + def test_can_create_artist_from_api(artists, mocker, db): mocker.patch( - 'musicbrainzngs.search_artists', - return_value=artists['search']['adhesive_wombat']) + "musicbrainzngs.search_artists", + return_value=artists["search"]["adhesive_wombat"], + ) artist = models.Artist.create_from_api(query="Adhesive wombat") - data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0] + data = models.Artist.api.search(query="Adhesive wombat")["artist-list"][0] - assert int(data['ext:score']), 100 - assert data['id'], '62c3befb-6366-4585-b256-809472333801' - assert artist.mbid, data['id'] - assert artist.name, 'Adhesive Wombat' + assert int(data["ext:score"]), 100 + assert data["id"], "62c3befb-6366-4585-b256-809472333801" + assert artist.mbid, data["id"] + assert artist.name, "Adhesive Wombat" def test_can_create_album_from_api(artists, albums, mocker, db): mocker.patch( - 'funkwhale_api.musicbrainz.api.releases.search', - return_value=albums['search']['hypnotize']) + "funkwhale_api.musicbrainz.api.releases.search", + return_value=albums["search"]["hypnotize"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.artists.get', - return_value=artists['get']['soad']) - album = models.Album.create_from_api(query="Hypnotize", artist='system of a down', type='album') - data = models.Album.api.search(query='Hypnotize', artist='system of a down', type='album')['release-list'][0] + "funkwhale_api.musicbrainz.api.artists.get", return_value=artists["get"]["soad"] + ) + album = models.Album.create_from_api( + query="Hypnotize", artist="system of a down", type="album" + ) + data = models.Album.api.search( + query="Hypnotize", artist="system of a down", type="album" + )["release-list"][0] - assert album.mbid, data['id'] - assert album.title, 'Hypnotize' + assert album.mbid, data["id"] + assert album.title, "Hypnotize" with pytest.raises(ValueError): assert album.cover.path is not None assert album.release_date, datetime.date(2005, 1, 1) - assert album.artist.name, 'System of a Down' - assert album.artist.mbid, data['artist-credit'][0]['artist']['id'] + assert album.artist.name, "System of a Down" + assert album.artist.mbid, data["artist-credit"][0]["artist"]["id"] def test_can_create_track_from_api(artists, albums, tracks, mocker, db): mocker.patch( - 'funkwhale_api.musicbrainz.api.artists.get', - return_value=artists['get']['adhesive_wombat']) + "funkwhale_api.musicbrainz.api.artists.get", + return_value=artists["get"]["adhesive_wombat"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.releases.get', - return_value=albums['get']['marsupial']) + "funkwhale_api.musicbrainz.api.releases.get", + return_value=albums["get"]["marsupial"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=tracks['search']['8bitadventures']) + "funkwhale_api.musicbrainz.api.recordings.search", + return_value=tracks["search"]["8bitadventures"], + ) track = models.Track.create_from_api(query="8-bit adventure") - data = models.Track.api.search(query='8-bit adventure')['recording-list'][0] - assert int(data['ext:score']) == 100 - assert data['id'] == '9968a9d6-8d92-4051-8f76-674e157b6eed' - assert track.mbid == data['id'] + data = models.Track.api.search(query="8-bit adventure")["recording-list"][0] + assert int(data["ext:score"]) == 100 + assert data["id"] == "9968a9d6-8d92-4051-8f76-674e157b6eed" + assert track.mbid == data["id"] assert track.artist.pk is not None - assert str(track.artist.mbid) == '62c3befb-6366-4585-b256-809472333801' - assert track.artist.name == 'Adhesive Wombat' - assert str(track.album.mbid) == 'a50d2a81-2a50-484d-9cb4-b9f6833f583e' - assert track.album.title == 'Marsupial Madness' + assert str(track.artist.mbid) == "62c3befb-6366-4585-b256-809472333801" + assert track.artist.name == "Adhesive Wombat" + assert str(track.album.mbid) == "a50d2a81-2a50-484d-9cb4-b9f6833f583e" + assert track.album.title == "Marsupial Madness" def test_can_create_track_from_api_with_corresponding_tags( - artists, albums, tracks, mocker, db): + artists, albums, tracks, mocker, db +): mocker.patch( - 'funkwhale_api.musicbrainz.api.artists.get', - return_value=artists['get']['adhesive_wombat']) + "funkwhale_api.musicbrainz.api.artists.get", + return_value=artists["get"]["adhesive_wombat"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.releases.get', - return_value=albums['get']['marsupial']) + "funkwhale_api.musicbrainz.api.releases.get", + return_value=albums["get"]["marsupial"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.recordings.get', - return_value=tracks['get']['8bitadventures']) - track = models.Track.create_from_api(id='9968a9d6-8d92-4051-8f76-674e157b6eed') - expected_tags = ['techno', 'good-music'] + "funkwhale_api.musicbrainz.api.recordings.get", + return_value=tracks["get"]["8bitadventures"], + ) + track = models.Track.create_from_api(id="9968a9d6-8d92-4051-8f76-674e157b6eed") + expected_tags = ["techno", "good-music"] track_tags = [tag.slug for tag in track.tags.all()] for tag in expected_tags: assert tag in track_tags -def test_can_get_or_create_track_from_api( - artists, albums, tracks, mocker, db): +def test_can_get_or_create_track_from_api(artists, albums, tracks, mocker, db): mocker.patch( - 'funkwhale_api.musicbrainz.api.artists.get', - return_value=artists['get']['adhesive_wombat']) + "funkwhale_api.musicbrainz.api.artists.get", + return_value=artists["get"]["adhesive_wombat"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.releases.get', - return_value=albums['get']['marsupial']) + "funkwhale_api.musicbrainz.api.releases.get", + return_value=albums["get"]["marsupial"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=tracks['search']['8bitadventures']) + "funkwhale_api.musicbrainz.api.recordings.search", + return_value=tracks["search"]["8bitadventures"], + ) track = models.Track.create_from_api(query="8-bit adventure") - data = models.Track.api.search(query='8-bit adventure')['recording-list'][0] - assert int(data['ext:score']) == 100 - assert data['id'] == '9968a9d6-8d92-4051-8f76-674e157b6eed' - assert track.mbid == data['id'] + data = models.Track.api.search(query="8-bit adventure")["recording-list"][0] + assert int(data["ext:score"]) == 100 + assert data["id"] == "9968a9d6-8d92-4051-8f76-674e157b6eed" + assert track.mbid == data["id"] assert track.artist.pk is not None - assert str(track.artist.mbid) == '62c3befb-6366-4585-b256-809472333801' - assert track.artist.name == 'Adhesive Wombat' + assert str(track.artist.mbid) == "62c3befb-6366-4585-b256-809472333801" + assert track.artist.name == "Adhesive Wombat" - track2, created = models.Track.get_or_create_from_api(mbid=data['id']) + track2, created = models.Track.get_or_create_from_api(mbid=data["id"]) assert not created assert track == track2 def test_album_tags_deduced_from_tracks_tags(factories, django_assert_num_queries): - tag = factories['taggit.Tag']() - album = factories['music.Album']() - tracks = factories['music.Track'].create_batch( - 5, album=album, tags=[tag]) + tag = factories["taggit.Tag"]() + album = factories["music.Album"]() + factories["music.Track"].create_batch(5, album=album, tags=[tag]) - album = models.Album.objects.prefetch_related('tracks__tags').get(pk=album.pk) + album = models.Album.objects.prefetch_related("tracks__tags").get(pk=album.pk) with django_assert_num_queries(0): assert tag in album.tags def test_artist_tags_deduced_from_album_tags(factories, django_assert_num_queries): - tag = factories['taggit.Tag']() - album = factories['music.Album']() + tag = factories["taggit.Tag"]() + album = factories["music.Album"]() artist = album.artist - tracks = factories['music.Track'].create_batch( - 5, album=album, tags=[tag]) + factories["music.Track"].create_batch(5, album=album, tags=[tag]) - artist = models.Artist.objects.prefetch_related('albums__tracks__tags').get(pk=artist.pk) + artist = models.Artist.objects.prefetch_related("albums__tracks__tags").get( + pk=artist.pk + ) with django_assert_num_queries(0): assert tag in artist.tags @@ -127,10 +144,10 @@ def test_artist_tags_deduced_from_album_tags(factories, django_assert_num_querie def test_can_download_image_file_for_album(binary_cover, mocker, factories): mocker.patch( - 'funkwhale_api.musicbrainz.api.images.get_front', - return_value=binary_cover) + "funkwhale_api.musicbrainz.api.images.get_front", return_value=binary_cover + ) # client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed') - album = factories['music.Album'](mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed') + album = factories["music.Album"](mbid="55ea4f82-b42b-423e-a0e5-290ccdf443ed") album.get_image() album.save() diff --git a/api/tests/music/test_permissions.py b/api/tests/music/test_permissions.py index 825d1731d..5f73a361e 100644 --- a/api/tests/music/test_permissions.py +++ b/api/tests/music/test_permissions.py @@ -5,58 +5,56 @@ from funkwhale_api.music import permissions def test_list_permission_no_protect(preferences, anonymous_user, api_request): - preferences['common__api_authentication_required'] = False + preferences["common__api_authentication_required"] = False view = APIView.as_view() permission = permissions.Listen() - request = api_request.get('/') + request = api_request.get("/") assert permission.has_permission(request, view) is True -def test_list_permission_protect_authenticated( - factories, api_request, preferences): - preferences['common__api_authentication_required'] = True - user = factories['users.User']() +def test_list_permission_protect_authenticated(factories, api_request, preferences): + preferences["common__api_authentication_required"] = True + user = factories["users.User"]() view = APIView.as_view() permission = permissions.Listen() - request = api_request.get('/') - setattr(request, 'user', user) + request = api_request.get("/") + setattr(request, "user", user) assert permission.has_permission(request, view) is True def test_list_permission_protect_not_following_actor( - factories, api_request, preferences): - preferences['common__api_authentication_required'] = True - actor = factories['federation.Actor']() + factories, api_request, preferences +): + preferences["common__api_authentication_required"] = True + actor = factories["federation.Actor"]() view = APIView.as_view() permission = permissions.Listen() - request = api_request.get('/') - setattr(request, 'actor', actor) + request = api_request.get("/") + setattr(request, "actor", actor) assert permission.has_permission(request, view) is False -def test_list_permission_protect_following_actor( - factories, api_request, preferences): - preferences['common__api_authentication_required'] = True - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow = factories['federation.Follow']( - approved=True, target=library_actor) +def test_list_permission_protect_following_actor(factories, api_request, preferences): + preferences["common__api_authentication_required"] = True + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + follow = factories["federation.Follow"](approved=True, target=library_actor) view = APIView.as_view() permission = permissions.Listen() - request = api_request.get('/') - setattr(request, 'actor', follow.actor) + request = api_request.get("/") + setattr(request, "actor", follow.actor) assert permission.has_permission(request, view) is True def test_list_permission_protect_following_actor_not_approved( - factories, api_request, preferences): - preferences['common__api_authentication_required'] = True - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow = factories['federation.Follow']( - approved=False, target=library_actor) + factories, api_request, preferences +): + preferences["common__api_authentication_required"] = True + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + follow = factories["federation.Follow"](approved=False, target=library_actor) view = APIView.as_view() permission = permissions.Listen() - request = api_request.get('/') - setattr(request, 'actor', follow.actor) + request = api_request.get("/") + setattr(request, "actor", follow.actor) assert permission.has_permission(request, view) is False diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index fa22cecee..51ca96b5d 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -2,18 +2,18 @@ from funkwhale_api.music import serializers def test_artist_album_serializer(factories, to_api_date): - track = factories['music.Track']() + track = factories["music.Track"]() album = track.album album = album.__class__.objects.with_tracks_count().get(pk=album.pk) expected = { - 'id': album.id, - 'mbid': str(album.mbid), - 'title': album.title, - 'artist': album.artist.id, - 'creation_date': to_api_date(album.creation_date), - 'tracks_count': 1, - 'cover': album.cover.url, - 'release_date': to_api_date(album.release_date), + "id": album.id, + "mbid": str(album.mbid), + "title": album.title, + "artist": album.artist.id, + "creation_date": to_api_date(album.creation_date), + "tracks_count": 1, + "cover": album.cover.url, + "release_date": to_api_date(album.release_date), } serializer = serializers.ArtistAlbumSerializer(album) @@ -21,79 +21,71 @@ def test_artist_album_serializer(factories, to_api_date): def test_artist_with_albums_serializer(factories, to_api_date): - track = factories['music.Track']() + track = factories["music.Track"]() artist = track.artist artist = artist.__class__.objects.with_albums().get(pk=artist.pk) album = list(artist.albums.all())[0] expected = { - 'id': artist.id, - 'mbid': str(artist.mbid), - 'name': artist.name, - 'creation_date': to_api_date(artist.creation_date), - 'albums': [ - serializers.ArtistAlbumSerializer(album).data - ] + "id": artist.id, + "mbid": str(artist.mbid), + "name": artist.name, + "creation_date": to_api_date(artist.creation_date), + "albums": [serializers.ArtistAlbumSerializer(album).data], } serializer = serializers.ArtistWithAlbumsSerializer(artist) assert serializer.data == expected def test_album_track_serializer(factories, to_api_date): - tf = factories['music.TrackFile']() + tf = factories["music.TrackFile"]() track = tf.track expected = { - 'id': track.id, - 'artist': track.artist.id, - 'album': track.album.id, - 'mbid': str(track.mbid), - 'title': track.title, - 'position': track.position, - 'creation_date': to_api_date(track.creation_date), - 'files': [ - serializers.TrackFileSerializer(tf).data - ] + "id": track.id, + "artist": track.artist.id, + "album": track.album.id, + "mbid": str(track.mbid), + "title": track.title, + "position": track.position, + "creation_date": to_api_date(track.creation_date), + "files": [serializers.TrackFileSerializer(tf).data], } serializer = serializers.AlbumTrackSerializer(track) assert serializer.data == expected def test_track_file_serializer(factories, to_api_date): - tf = factories['music.TrackFile']() + tf = factories["music.TrackFile"]() expected = { - 'id': tf.id, - 'path': tf.path, - 'source': tf.source, - 'filename': tf.filename, - 'mimetype': tf.mimetype, - 'track': tf.track.pk, - 'duration': tf.duration, - 'mimetype': tf.mimetype, - 'bitrate': tf.bitrate, - 'size': tf.size, + "id": tf.id, + "path": tf.path, + "source": tf.source, + "filename": tf.filename, + "track": tf.track.pk, + "duration": tf.duration, + "mimetype": tf.mimetype, + "bitrate": tf.bitrate, + "size": tf.size, } serializer = serializers.TrackFileSerializer(tf) assert serializer.data == expected def test_album_serializer(factories, to_api_date): - track1 = factories['music.Track'](position=2) - track2 = factories['music.Track'](position=1, album=track1.album) + track1 = factories["music.Track"](position=2) + track2 = factories["music.Track"](position=1, album=track1.album) album = track1.album expected = { - 'id': album.id, - 'mbid': str(album.mbid), - 'title': album.title, - 'artist': serializers.ArtistSimpleSerializer(album.artist).data, - 'creation_date': to_api_date(album.creation_date), - 'cover': album.cover.url, - 'release_date': to_api_date(album.release_date), - 'tracks': serializers.AlbumTrackSerializer( - [track2, track1], - many=True - ).data + "id": album.id, + "mbid": str(album.mbid), + "title": album.title, + "artist": serializers.ArtistSimpleSerializer(album.artist).data, + "creation_date": to_api_date(album.creation_date), + "cover": album.cover.url, + "release_date": to_api_date(album.release_date), + "tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data, } serializer = serializers.AlbumSerializer(album) @@ -101,21 +93,19 @@ def test_album_serializer(factories, to_api_date): def test_track_serializer(factories, to_api_date): - tf = factories['music.TrackFile']() + tf = factories["music.TrackFile"]() track = tf.track expected = { - 'id': track.id, - 'artist': serializers.ArtistSimpleSerializer(track.artist).data, - 'album': serializers.TrackAlbumSerializer(track.album).data, - 'mbid': str(track.mbid), - 'title': track.title, - 'position': track.position, - 'creation_date': to_api_date(track.creation_date), - 'lyrics': track.get_lyrics_url(), - 'files': [ - serializers.TrackFileSerializer(tf).data - ] + "id": track.id, + "artist": serializers.ArtistSimpleSerializer(track.artist).data, + "album": serializers.TrackAlbumSerializer(track.album).data, + "mbid": str(track.mbid), + "title": track.title, + "position": track.position, + "creation_date": to_api_date(track.creation_date), + "lyrics": track.get_lyrics_url(), + "files": [serializers.TrackFileSerializer(tf).data], } serializer = serializers.TrackSerializer(track) assert serializer.data == expected diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index 77245e204..71d605b2b 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -1,181 +1,183 @@ import os + import pytest -from funkwhale_api.providers.acoustid import get_acoustid_client from funkwhale_api.music import tasks DATA_DIR = os.path.dirname(os.path.abspath(__file__)) def test_set_acoustid_on_track_file(factories, mocker, preferences): - preferences['providers_acoustid__api_key'] = 'test' - track_file = factories['music.TrackFile'](acoustid_track_id=None) - id = 'e475bf79-c1ce-4441-bed7-1e33f226c0a2' + preferences["providers_acoustid__api_key"] = "test" + track_file = factories["music.TrackFile"](acoustid_track_id=None) + id = "e475bf79-c1ce-4441-bed7-1e33f226c0a2" payload = { - 'results': [ - {'id': id, - 'recordings': [ - {'artists': [ - {'id': '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13', - 'name': 'Binärpilot'}], - 'duration': 268, - 'id': 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb', - 'title': 'Bend'}], - 'score': 0.860825}], - 'status': 'ok' + "results": [ + { + "id": id, + "recordings": [ + { + "artists": [ + { + "id": "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13", + "name": "Binärpilot", + } + ], + "duration": 268, + "id": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", + "title": "Bend", + } + ], + "score": 0.860825, + } + ], + "status": "ok", } - m = mocker.patch('acoustid.match', return_value=payload) + m = mocker.patch("acoustid.match", return_value=payload) r = tasks.set_acoustid_on_track_file(track_file_id=track_file.pk) track_file.refresh_from_db() assert str(track_file.acoustid_track_id) == id assert r == id - m.assert_called_once_with('test', track_file.audio_file.path, parse=False) + m.assert_called_once_with("test", track_file.audio_file.path, parse=False) def test_set_acoustid_on_track_file_required_high_score(factories, mocker): - track_file = factories['music.TrackFile'](acoustid_track_id=None) - id = 'e475bf79-c1ce-4441-bed7-1e33f226c0a2' - payload = { - 'results': [{'score': 0.79}], - 'status': 'ok' - } - m = mocker.patch('acoustid.match', return_value=payload) - r = tasks.set_acoustid_on_track_file(track_file_id=track_file.pk) + track_file = factories["music.TrackFile"](acoustid_track_id=None) + payload = {"results": [{"score": 0.79}], "status": "ok"} + mocker.patch("acoustid.match", return_value=payload) + tasks.set_acoustid_on_track_file(track_file_id=track_file.pk) track_file.refresh_from_db() assert track_file.acoustid_track_id is None def test_import_batch_run(factories, mocker): - job = factories['music.ImportJob']() - mocked_job_run = mocker.patch( - 'funkwhale_api.music.tasks.import_job_run.delay') + job = factories["music.ImportJob"]() + mocked_job_run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") tasks.import_batch_run(import_batch_id=job.batch.pk) mocked_job_run.assert_called_once_with(import_job_id=job.pk) -@pytest.mark.skip('Acoustid is disabled') +@pytest.mark.skip("Acoustid is disabled") def test_import_job_can_run_with_file_and_acoustid( - artists, albums, tracks, preferences, factories, mocker): - preferences['providers_acoustid__api_key'] = 'test' - path = os.path.join(DATA_DIR, 'test.ogg') - mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' + artists, albums, tracks, preferences, factories, mocker +): + preferences["providers_acoustid__api_key"] = "test" + path = os.path.join(DATA_DIR, "test.ogg") + mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed" acoustid_payload = { - 'results': [ - {'id': 'e475bf79-c1ce-4441-bed7-1e33f226c0a2', - 'recordings': [ - { - 'duration': 268, - 'id': mbid}], - 'score': 0.860825}], - 'status': 'ok' + "results": [ + { + "id": "e475bf79-c1ce-4441-bed7-1e33f226c0a2", + "recordings": [{"duration": 268, "id": mbid}], + "score": 0.860825, + } + ], + "status": "ok", } mocker.patch( - 'funkwhale_api.music.utils.get_audio_file_data', - return_value={'bitrate': 42, 'length': 43}) + "funkwhale_api.music.utils.get_audio_file_data", + return_value={"bitrate": 42, "length": 43}, + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.artists.get', - return_value=artists['get']['adhesive_wombat']) + "funkwhale_api.musicbrainz.api.artists.get", + return_value=artists["get"]["adhesive_wombat"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.releases.get', - return_value=albums['get']['marsupial']) + "funkwhale_api.musicbrainz.api.releases.get", + return_value=albums["get"]["marsupial"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=tracks['search']['8bitadventures']) - mocker.patch('acoustid.match', return_value=acoustid_payload) + "funkwhale_api.musicbrainz.api.recordings.search", + return_value=tracks["search"]["8bitadventures"], + ) + mocker.patch("acoustid.match", return_value=acoustid_payload) - job = factories['music.FileImportJob'](audio_file__path=path) + job = factories["music.FileImportJob"](audio_file__path=path) f = job.audio_file tasks.import_job_run(import_job_id=job.pk) job.refresh_from_db() track_file = job.track_file - with open(path, 'rb') as f: + with open(path, "rb") as f: assert track_file.audio_file.read() == f.read() assert track_file.bitrate == 42 assert track_file.duration == 43 assert track_file.size == os.path.getsize(path) # audio file is deleted from import job once persisted to audio file assert not job.audio_file - assert job.status == 'finished' - assert job.source == 'file://' + assert job.status == "finished" + assert job.source == "file://" def test_run_import_skipping_accoustid(factories, mocker): - m = mocker.patch('funkwhale_api.music.tasks._do_import') - path = os.path.join(DATA_DIR, 'test.ogg') - job = factories['music.FileImportJob'](audio_file__path=path) + m = mocker.patch("funkwhale_api.music.tasks._do_import") + path = os.path.join(DATA_DIR, "test.ogg") + job = factories["music.FileImportJob"](audio_file__path=path) tasks.import_job_run(import_job_id=job.pk, use_acoustid=False) m.assert_called_once_with(job, False, use_acoustid=False) def test__do_import_skipping_accoustid(factories, mocker): - t = factories['music.Track']() + t = factories["music.Track"]() m = mocker.patch( - 'funkwhale_api.providers.audiofile.tasks.import_track_data_from_path', - return_value=t) - path = os.path.join(DATA_DIR, 'test.ogg') - job = factories['music.FileImportJob']( - mbid=None, - audio_file__path=path) + "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", + return_value=t, + ) + path = os.path.join(DATA_DIR, "test.ogg") + job = factories["music.FileImportJob"](mbid=None, audio_file__path=path) p = job.audio_file.path tasks._do_import(job, replace=False, use_acoustid=False) m.assert_called_once_with(p) -def test__do_import_skipping_accoustid_if_no_key( - factories, mocker, preferences): - preferences['providers_acoustid__api_key'] = '' - t = factories['music.Track']() +def test__do_import_skipping_accoustid_if_no_key(factories, mocker, preferences): + preferences["providers_acoustid__api_key"] = "" + t = factories["music.Track"]() m = mocker.patch( - 'funkwhale_api.providers.audiofile.tasks.import_track_data_from_path', - return_value=t) - path = os.path.join(DATA_DIR, 'test.ogg') - job = factories['music.FileImportJob']( - mbid=None, - audio_file__path=path) + "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", + return_value=t, + ) + path = os.path.join(DATA_DIR, "test.ogg") + job = factories["music.FileImportJob"](mbid=None, audio_file__path=path) p = job.audio_file.path tasks._do_import(job, replace=False, use_acoustid=False) m.assert_called_once_with(p) -def test_import_job_skip_if_already_exists( - artists, albums, tracks, factories, mocker): - path = os.path.join(DATA_DIR, 'test.ogg') - mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' - track_file = factories['music.TrackFile'](track__mbid=mbid) +def test_import_job_skip_if_already_exists(artists, albums, tracks, factories, mocker): + path = os.path.join(DATA_DIR, "test.ogg") + mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed" + track_file = factories["music.TrackFile"](track__mbid=mbid) mocker.patch( - 'funkwhale_api.providers.audiofile.tasks.import_track_data_from_path', - return_value=track_file.track) + "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", + return_value=track_file.track, + ) - job = factories['music.FileImportJob'](audio_file__path=path) - f = job.audio_file + job = factories["music.FileImportJob"](audio_file__path=path) tasks.import_job_run(import_job_id=job.pk) job.refresh_from_db() assert job.track_file is None # audio file is deleted from import job once persisted to audio file assert not job.audio_file - assert job.status == 'skipped' + assert job.status == "skipped" def test_import_job_can_be_errored(factories, mocker, preferences): - path = os.path.join(DATA_DIR, 'test.ogg') - mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' - track_file = factories['music.TrackFile'](track__mbid=mbid) + path = os.path.join(DATA_DIR, "test.ogg") + mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed" + factories["music.TrackFile"](track__mbid=mbid) class MyException(Exception): pass - mocker.patch( - 'funkwhale_api.music.tasks._do_import', - side_effect=MyException()) + mocker.patch("funkwhale_api.music.tasks._do_import", side_effect=MyException()) - job = factories['music.FileImportJob']( - audio_file__path=path, track_file=None) + job = factories["music.FileImportJob"](audio_file__path=path, track_file=None) with pytest.raises(MyException): tasks.import_job_run(import_job_id=job.pk) @@ -183,23 +185,22 @@ def test_import_job_can_be_errored(factories, mocker, preferences): job.refresh_from_db() assert job.track_file is None - assert job.status == 'errored' + assert job.status == "errored" def test__do_import_calls_update_album_cover_if_no_cover(factories, mocker): - path = os.path.join(DATA_DIR, 'test.ogg') - album = factories['music.Album'](cover='') - track = factories['music.Track'](album=album) + path = os.path.join(DATA_DIR, "test.ogg") + album = factories["music.Album"](cover="") + track = factories["music.Track"](album=album) mocker.patch( - 'funkwhale_api.providers.audiofile.tasks.import_track_data_from_path', - return_value=track) + "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", + return_value=track, + ) - mocked_update = mocker.patch( - 'funkwhale_api.music.tasks.update_album_cover') + mocked_update = mocker.patch("funkwhale_api.music.tasks.update_album_cover") - job = factories['music.FileImportJob']( - audio_file__path=path, track_file=None) + job = factories["music.FileImportJob"](audio_file__path=path, track_file=None) tasks.import_job_run(import_job_id=job.pk) @@ -207,50 +208,41 @@ def test__do_import_calls_update_album_cover_if_no_cover(factories, mocker): def test_update_album_cover_mbid(factories, mocker): - album = factories['music.Album'](cover='') + album = factories["music.Album"](cover="") - mocked_get = mocker.patch('funkwhale_api.music.models.Album.get_image') + mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") tasks.update_album_cover(album=album, track_file=None) mocked_get.assert_called_once_with() def test_update_album_cover_file_data(factories, mocker): - path = os.path.join(DATA_DIR, 'test.mp3') - album = factories['music.Album'](cover='', mbid=None) - tf = factories['music.TrackFile'](track__album=album) + album = factories["music.Album"](cover="", mbid=None) + tf = factories["music.TrackFile"](track__album=album) - mocked_get = mocker.patch('funkwhale_api.music.models.Album.get_image') + mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") mocker.patch( - 'funkwhale_api.music.metadata.Metadata.get_picture', - return_value={'hello': 'world'}) + "funkwhale_api.music.metadata.Metadata.get_picture", + return_value={"hello": "world"}, + ) tasks.update_album_cover(album=album, track_file=tf) - md = data = tf.get_metadata() - mocked_get.assert_called_once_with( - data={'hello': 'world'}) + tf.get_metadata() + mocked_get.assert_called_once_with(data={"hello": "world"}) -@pytest.mark.parametrize('ext,mimetype', [ - ('jpg', 'image/jpeg'), - ('png', 'image/png'), -]) -def test_update_album_cover_file_cover_separate_file( - ext, mimetype, factories, mocker): - mocker.patch('funkwhale_api.music.tasks.IMAGE_TYPES', [(ext, mimetype)]) - path = os.path.join(DATA_DIR, 'test.mp3') - image_path = os.path.join(DATA_DIR, 'cover.{}'.format(ext)) - with open(image_path, 'rb') as f: +@pytest.mark.parametrize("ext,mimetype", [("jpg", "image/jpeg"), ("png", "image/png")]) +def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, mocker): + mocker.patch("funkwhale_api.music.tasks.IMAGE_TYPES", [(ext, mimetype)]) + image_path = os.path.join(DATA_DIR, "cover.{}".format(ext)) + with open(image_path, "rb") as f: image_content = f.read() - album = factories['music.Album'](cover='', mbid=None) - tf = factories['music.TrackFile']( - track__album=album, - source='file://' + image_path) + album = factories["music.Album"](cover="", mbid=None) + tf = factories["music.TrackFile"](track__album=album, source="file://" + image_path) - mocked_get = mocker.patch('funkwhale_api.music.models.Album.get_image') - mocker.patch( - 'funkwhale_api.music.metadata.Metadata.get_picture', - return_value=None) + mocked_get = mocker.patch("funkwhale_api.music.models.Album.get_image") + mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture", return_value=None) tasks.update_album_cover(album=album, track_file=tf) - md = data = tf.get_metadata() + tf.get_metadata() mocked_get.assert_called_once_with( - data={'mimetype': mimetype, 'content': image_content}) + data={"mimetype": mimetype, "content": image_content} + ) diff --git a/api/tests/music/test_utils.py b/api/tests/music/test_utils.py index 7b967dbbc..4019e47b4 100644 --- a/api/tests/music/test_utils.py +++ b/api/tests/music/test_utils.py @@ -1,4 +1,5 @@ import os + import pytest from funkwhale_api.music import utils @@ -7,35 +8,31 @@ DATA_DIR = os.path.dirname(os.path.abspath(__file__)) def test_guess_mimetype_try_using_extension(factories, mocker): - mocker.patch( - 'magic.from_buffer', return_value='audio/mpeg') - f = factories['music.TrackFile'].build( - audio_file__filename='test.ogg') + mocker.patch("magic.from_buffer", return_value="audio/mpeg") + f = factories["music.TrackFile"].build(audio_file__filename="test.ogg") - assert utils.guess_mimetype(f.audio_file) == 'audio/mpeg' + assert utils.guess_mimetype(f.audio_file) == "audio/mpeg" -@pytest.mark.parametrize('wrong', [ - 'application/octet-stream', - 'application/x-empty', -]) +@pytest.mark.parametrize("wrong", ["application/octet-stream", "application/x-empty"]) def test_guess_mimetype_try_using_extension_if_fail(wrong, factories, mocker): - mocker.patch( - 'magic.from_buffer', return_value=wrong) - f = factories['music.TrackFile'].build( - audio_file__filename='test.mp3') + mocker.patch("magic.from_buffer", return_value=wrong) + f = factories["music.TrackFile"].build(audio_file__filename="test.mp3") - assert utils.guess_mimetype(f.audio_file) == 'audio/mpeg' + assert utils.guess_mimetype(f.audio_file) == "audio/mpeg" -@pytest.mark.parametrize('name, expected', [ - ('sample.flac', {'bitrate': 1608000, 'length': 0.001}), - ('test.mp3', {'bitrate': 8000, 'length': 267.70285714285717}), - ('test.ogg', {'bitrate': 128000, 'length': 229.18304166666667}), -]) +@pytest.mark.parametrize( + "name, expected", + [ + ("sample.flac", {"bitrate": 1608000, "length": 0.001}), + ("test.mp3", {"bitrate": 8000, "length": 267.70285714285717}), + ("test.ogg", {"bitrate": 128000, "length": 229.18304166666667}), + ], +) def test_get_audio_file_data(name, expected): path = os.path.join(DATA_DIR, name) - with open(path, 'rb') as f: + with open(path, "rb") as f: result = utils.get_audio_file_data(f) assert result == expected diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 91fef13f2..aa04521cb 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -1,36 +1,34 @@ import io -import pytest +import pytest from django.urls import reverse from django.utils import timezone -from funkwhale_api.music import serializers -from funkwhale_api.music import views from funkwhale_api.federation import actors +from funkwhale_api.music import serializers, views -@pytest.mark.parametrize('view,permissions,operator', [ - (views.ImportBatchViewSet, ['library', 'upload'], 'or'), - (views.ImportJobViewSet, ['library', 'upload'], 'or'), -]) +@pytest.mark.parametrize( + "view,permissions,operator", + [ + (views.ImportBatchViewSet, ["library", "upload"], "or"), + (views.ImportJobViewSet, ["library", "upload"], "or"), + ], +) def test_permissions(assert_user_permission, view, permissions, operator): assert_user_permission(view, permissions, operator) def test_artist_list_serializer(api_request, factories, logged_in_api_client): - track = factories['music.Track']() + track = factories["music.Track"]() artist = track.artist - request = api_request.get('/') + request = api_request.get("/") qs = artist.__class__.objects.with_albums() serializer = serializers.ArtistWithAlbumsSerializer( - qs, many=True, context={'request': request}) - expected = { - 'count': 1, - 'next': None, - 'previous': None, - 'results': serializer.data - } - url = reverse('api:v1:artists-list') + qs, many=True, context={"request": request} + ) + expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} + url = reverse("api:v1:artists-list") response = logged_in_api_client.get(url) assert response.status_code == 200 @@ -38,19 +36,15 @@ def test_artist_list_serializer(api_request, factories, logged_in_api_client): def test_album_list_serializer(api_request, factories, logged_in_api_client): - track = factories['music.Track']() + track = factories["music.Track"]() album = track.album - request = api_request.get('/') + request = api_request.get("/") qs = album.__class__.objects.all() serializer = serializers.AlbumSerializer( - qs, many=True, context={'request': request}) - expected = { - 'count': 1, - 'next': None, - 'previous': None, - 'results': serializer.data - } - url = reverse('api:v1:albums-list') + qs, many=True, context={"request": request} + ) + expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} + url = reverse("api:v1:albums-list") response = logged_in_api_client.get(url) assert response.status_code == 200 @@ -58,38 +52,30 @@ def test_album_list_serializer(api_request, factories, logged_in_api_client): def test_track_list_serializer(api_request, factories, logged_in_api_client): - track = factories['music.Track']() - request = api_request.get('/') + track = factories["music.Track"]() + request = api_request.get("/") qs = track.__class__.objects.all() serializer = serializers.TrackSerializer( - qs, many=True, context={'request': request}) - expected = { - 'count': 1, - 'next': None, - 'previous': None, - 'results': serializer.data - } - url = reverse('api:v1:tracks-list') + qs, many=True, context={"request": request} + ) + expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} + url = reverse("api:v1:tracks-list") response = logged_in_api_client.get(url) assert response.status_code == 200 assert response.data == expected -@pytest.mark.parametrize('param,expected', [ - ('true', 'full'), - ('false', 'empty'), -]) -def test_artist_view_filter_listenable( - param, expected, factories, api_request): +@pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")]) +def test_artist_view_filter_listenable(param, expected, factories, api_request): artists = { - 'empty': factories['music.Artist'](), - 'full': factories['music.TrackFile']().track.artist, + "empty": factories["music.Artist"](), + "full": factories["music.TrackFile"]().track.artist, } - request = api_request.get('/', {'listenable': param}) + request = api_request.get("/", {"listenable": param}) view = views.ArtistViewSet() - view.action_map = {'get': 'list'} + view.action_map = {"get": "list"} expected = [artists[expected]] view.request = view.initialize_request(request) queryset = view.filter_queryset(view.get_queryset()) @@ -97,20 +83,16 @@ def test_artist_view_filter_listenable( assert list(queryset) == expected -@pytest.mark.parametrize('param,expected', [ - ('true', 'full'), - ('false', 'empty'), -]) -def test_album_view_filter_listenable( - param, expected, factories, api_request): +@pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")]) +def test_album_view_filter_listenable(param, expected, factories, api_request): artists = { - 'empty': factories['music.Album'](), - 'full': factories['music.TrackFile']().track.album, + "empty": factories["music.Album"](), + "full": factories["music.TrackFile"]().track.album, } - request = api_request.get('/', {'listenable': param}) + request = api_request.get("/", {"listenable": param}) view = views.AlbumViewSet() - view.action_map = {'get': 'list'} + view.action_map = {"get": "list"} expected = [artists[expected]] view.request = view.initialize_request(request) queryset = view.filter_queryset(view.get_queryset()) @@ -119,58 +101,53 @@ def test_album_view_filter_listenable( def test_can_serve_track_file_as_remote_library( - factories, authenticated_actor, api_client, settings, preferences): - preferences['common__api_authentication_required'] = True - library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() - follow = factories['federation.Follow']( - approved=True, - actor=authenticated_actor, - target=library_actor) + factories, authenticated_actor, api_client, settings, preferences +): + preferences["common__api_authentication_required"] = True + library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance() + factories["federation.Follow"]( + approved=True, actor=authenticated_actor, target=library_actor + ) - track_file = factories['music.TrackFile']() + track_file = factories["music.TrackFile"]() response = api_client.get(track_file.path) assert response.status_code == 200 - assert response['X-Accel-Redirect'] == "{}{}".format( - settings.PROTECT_FILES_PATH, - track_file.audio_file.url) + assert response["X-Accel-Redirect"] == "{}{}".format( + settings.PROTECT_FILES_PATH, track_file.audio_file.url + ) def test_can_serve_track_file_as_remote_library_deny_not_following( - factories, authenticated_actor, settings, api_client, preferences): - preferences['common__api_authentication_required'] = True - track_file = factories['music.TrackFile']() + factories, authenticated_actor, settings, api_client, preferences +): + preferences["common__api_authentication_required"] = True + track_file = factories["music.TrackFile"]() response = api_client.get(track_file.path) assert response.status_code == 403 -@pytest.mark.parametrize('proxy,serve_path,expected', [ - ('apache2', '/host/music', '/host/music/hello/world.mp3'), - ('apache2', '/app/music', '/app/music/hello/world.mp3'), - ('nginx', '/host/music', '/_protected/music/hello/world.mp3'), - ('nginx', '/app/music', '/_protected/music/hello/world.mp3'), -]) +@pytest.mark.parametrize( + "proxy,serve_path,expected", + [ + ("apache2", "/host/music", "/host/music/hello/world.mp3"), + ("apache2", "/app/music", "/app/music/hello/world.mp3"), + ("nginx", "/host/music", "/_protected/music/hello/world.mp3"), + ("nginx", "/app/music", "/_protected/music/hello/world.mp3"), + ], +) def test_serve_file_in_place( - proxy, - serve_path, - expected, - factories, - api_client, - preferences, - settings): - headers = { - 'apache2': 'X-Sendfile', - 'nginx': 'X-Accel-Redirect', - } - preferences['common__api_authentication_required'] = False - settings.PROTECT_FILE_PATH = '/_protected/music' + proxy, serve_path, expected, factories, api_client, preferences, settings +): + headers = {"apache2": "X-Sendfile", "nginx": "X-Accel-Redirect"} + preferences["common__api_authentication_required"] = False + settings.PROTECT_FILE_PATH = "/_protected/music" settings.REVERSE_PROXY_TYPE = proxy - settings.MUSIC_DIRECTORY_PATH = '/app/music' + settings.MUSIC_DIRECTORY_PATH = "/app/music" settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path - tf = factories['music.TrackFile']( - in_place=True, - source='file:///app/music/hello/world.mp3' + tf = factories["music.TrackFile"]( + in_place=True, source="file:///app/music/hello/world.mp3" ) response = api_client.get(tf.path) @@ -178,86 +155,76 @@ def test_serve_file_in_place( assert response[headers[proxy]] == expected -@pytest.mark.parametrize('proxy,serve_path,expected', [ - ('apache2', '/host/music', '/host/music/hello/worldéà.mp3'), - ('apache2', '/app/music', '/app/music/hello/worldéà.mp3'), - ('nginx', '/host/music', '/_protected/music/hello/worldéà.mp3'), - ('nginx', '/app/music', '/_protected/music/hello/worldéà.mp3'), -]) +@pytest.mark.parametrize( + "proxy,serve_path,expected", + [ + ("apache2", "/host/music", "/host/music/hello/worldéà.mp3"), + ("apache2", "/app/music", "/app/music/hello/worldéà.mp3"), + ("nginx", "/host/music", "/_protected/music/hello/worldéà.mp3"), + ("nginx", "/app/music", "/_protected/music/hello/worldéà.mp3"), + ], +) def test_serve_file_in_place_utf8( - proxy, - serve_path, - expected, - factories, - api_client, - settings, - preferences): - preferences['common__api_authentication_required'] = False - settings.PROTECT_FILE_PATH = '/_protected/music' + proxy, serve_path, expected, factories, api_client, settings, preferences +): + preferences["common__api_authentication_required"] = False + settings.PROTECT_FILE_PATH = "/_protected/music" settings.REVERSE_PROXY_TYPE = proxy - settings.MUSIC_DIRECTORY_PATH = '/app/music' + settings.MUSIC_DIRECTORY_PATH = "/app/music" settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path - path = views.get_file_path('/app/music/hello/worldéà.mp3') + path = views.get_file_path("/app/music/hello/worldéà.mp3") - assert path == expected.encode('utf-8') + assert path == expected.encode("utf-8") -@pytest.mark.parametrize('proxy,serve_path,expected', [ - ('apache2', '/host/music', '/host/media/tracks/hello/world.mp3'), - # apache with container not supported yet - # ('apache2', '/app/music', '/app/music/tracks/hello/world.mp3'), - ('nginx', '/host/music', '/_protected/media/tracks/hello/world.mp3'), - ('nginx', '/app/music', '/_protected/media/tracks/hello/world.mp3'), -]) +@pytest.mark.parametrize( + "proxy,serve_path,expected", + [ + ("apache2", "/host/music", "/host/media/tracks/hello/world.mp3"), + # apache with container not supported yet + # ('apache2', '/app/music', '/app/music/tracks/hello/world.mp3'), + ("nginx", "/host/music", "/_protected/media/tracks/hello/world.mp3"), + ("nginx", "/app/music", "/_protected/media/tracks/hello/world.mp3"), + ], +) def test_serve_file_media( - proxy, - serve_path, - expected, - factories, - api_client, - settings, - preferences): - headers = { - 'apache2': 'X-Sendfile', - 'nginx': 'X-Accel-Redirect', - } - preferences['common__api_authentication_required'] = False - settings.MEDIA_ROOT = '/host/media' - settings.PROTECT_FILE_PATH = '/_protected/music' + proxy, serve_path, expected, factories, api_client, settings, preferences +): + headers = {"apache2": "X-Sendfile", "nginx": "X-Accel-Redirect"} + preferences["common__api_authentication_required"] = False + settings.MEDIA_ROOT = "/host/media" + settings.PROTECT_FILE_PATH = "/_protected/music" settings.REVERSE_PROXY_TYPE = proxy - settings.MUSIC_DIRECTORY_PATH = '/app/music' + settings.MUSIC_DIRECTORY_PATH = "/app/music" settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path - tf = factories['music.TrackFile']() - tf.__class__.objects.filter(pk=tf.pk).update( - audio_file='tracks/hello/world.mp3') + tf = factories["music.TrackFile"]() + tf.__class__.objects.filter(pk=tf.pk).update(audio_file="tracks/hello/world.mp3") response = api_client.get(tf.path) assert response.status_code == 200 assert response[headers[proxy]] == expected -def test_can_proxy_remote_track( - factories, settings, api_client, r_mock, preferences): - preferences['common__api_authentication_required'] = False - track_file = factories['music.TrackFile'](federation=True) +def test_can_proxy_remote_track(factories, settings, api_client, r_mock, preferences): + preferences["common__api_authentication_required"] = False + track_file = factories["music.TrackFile"](federation=True) - r_mock.get(track_file.library_track.audio_url, body=io.BytesIO(b'test')) + r_mock.get(track_file.library_track.audio_url, body=io.BytesIO(b"test")) response = api_client.get(track_file.path) library_track = track_file.library_track library_track.refresh_from_db() assert response.status_code == 200 - assert response['X-Accel-Redirect'] == "{}{}".format( - settings.PROTECT_FILES_PATH, - library_track.audio_file.url) - assert library_track.audio_file.read() == b'test' + assert response["X-Accel-Redirect"] == "{}{}".format( + settings.PROTECT_FILES_PATH, library_track.audio_file.url + ) + assert library_track.audio_file.read() == b"test" -def test_serve_updates_access_date( - factories, settings, api_client, preferences): - preferences['common__api_authentication_required'] = False - track_file = factories['music.TrackFile']() +def test_serve_updates_access_date(factories, settings, api_client, preferences): + preferences["common__api_authentication_required"] = False + track_file = factories["music.TrackFile"]() now = timezone.now() assert track_file.accessed_date is None @@ -269,128 +236,118 @@ def test_serve_updates_access_date( def test_can_list_import_jobs(factories, superuser_api_client): - job = factories['music.ImportJob']() - url = reverse('api:v1:import-jobs-list') + job = factories["music.ImportJob"]() + url = reverse("api:v1:import-jobs-list") response = superuser_api_client.get(url) assert response.status_code == 200 - assert response.data['results'][0]['id'] == job.pk + assert response.data["results"][0]["id"] == job.pk def test_import_job_stats(factories, superuser_api_client): - job1 = factories['music.ImportJob'](status='pending') - job2 = factories['music.ImportJob'](status='errored') + factories["music.ImportJob"](status="pending") + factories["music.ImportJob"](status="errored") - url = reverse('api:v1:import-jobs-stats') + url = reverse("api:v1:import-jobs-stats") response = superuser_api_client.get(url) - expected = { - 'errored': 1, - 'pending': 1, - 'finished': 0, - 'skipped': 0, - 'count': 2, - } + expected = {"errored": 1, "pending": 1, "finished": 0, "skipped": 0, "count": 2} assert response.status_code == 200 assert response.data == expected def test_import_job_stats_filter(factories, superuser_api_client): - job1 = factories['music.ImportJob'](status='pending') - job2 = factories['music.ImportJob'](status='errored') + job1 = factories["music.ImportJob"](status="pending") + factories["music.ImportJob"](status="errored") - url = reverse('api:v1:import-jobs-stats') - response = superuser_api_client.get(url, {'batch': job1.batch.pk}) - expected = { - 'errored': 0, - 'pending': 1, - 'finished': 0, - 'skipped': 0, - 'count': 1, - } + url = reverse("api:v1:import-jobs-stats") + response = superuser_api_client.get(url, {"batch": job1.batch.pk}) + expected = {"errored": 0, "pending": 1, "finished": 0, "skipped": 0, "count": 1} assert response.status_code == 200 assert response.data == expected def test_import_job_run_via_api(factories, superuser_api_client, mocker): - run = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay') - job1 = factories['music.ImportJob'](status='errored') - job2 = factories['music.ImportJob'](status='pending') + run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") + job1 = factories["music.ImportJob"](status="errored") + job2 = factories["music.ImportJob"](status="pending") - url = reverse('api:v1:import-jobs-run') - response = superuser_api_client.post(url, {'jobs': [job2.pk, job1.pk]}) + url = reverse("api:v1:import-jobs-run") + response = superuser_api_client.post(url, {"jobs": [job2.pk, job1.pk]}) job1.refresh_from_db() job2.refresh_from_db() assert response.status_code == 200 - assert response.data == {'jobs': [job1.pk, job2.pk]} - assert job1.status == 'pending' - assert job2.status == 'pending' + assert response.data == {"jobs": [job1.pk, job2.pk]} + assert job1.status == "pending" + assert job2.status == "pending" run.assert_any_call(import_job_id=job1.pk) run.assert_any_call(import_job_id=job2.pk) def test_import_batch_run_via_api(factories, superuser_api_client, mocker): - run = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay') + run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") - batch = factories['music.ImportBatch']() - job1 = factories['music.ImportJob'](batch=batch, status='errored') - job2 = factories['music.ImportJob'](batch=batch, status='pending') + batch = factories["music.ImportBatch"]() + job1 = factories["music.ImportJob"](batch=batch, status="errored") + job2 = factories["music.ImportJob"](batch=batch, status="pending") - url = reverse('api:v1:import-jobs-run') - response = superuser_api_client.post(url, {'batches': [batch.pk]}) + url = reverse("api:v1:import-jobs-run") + response = superuser_api_client.post(url, {"batches": [batch.pk]}) job1.refresh_from_db() job2.refresh_from_db() assert response.status_code == 200 - assert job1.status == 'pending' - assert job2.status == 'pending' + assert job1.status == "pending" + assert job2.status == "pending" run.assert_any_call(import_job_id=job1.pk) run.assert_any_call(import_job_id=job2.pk) -def test_import_batch_and_job_run_via_api( - factories, superuser_api_client, mocker): - run = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay') +def test_import_batch_and_job_run_via_api(factories, superuser_api_client, mocker): + run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay") - batch = factories['music.ImportBatch']() - job1 = factories['music.ImportJob'](batch=batch, status='errored') - job2 = factories['music.ImportJob'](status='pending') + batch = factories["music.ImportBatch"]() + job1 = factories["music.ImportJob"](batch=batch, status="errored") + job2 = factories["music.ImportJob"](status="pending") - url = reverse('api:v1:import-jobs-run') + url = reverse("api:v1:import-jobs-run") response = superuser_api_client.post( - url, {'batches': [batch.pk], 'jobs': [job2.pk]}) + url, {"batches": [batch.pk], "jobs": [job2.pk]} + ) job1.refresh_from_db() job2.refresh_from_db() assert response.status_code == 200 - assert job1.status == 'pending' - assert job2.status == 'pending' + assert job1.status == "pending" + assert job2.status == "pending" run.assert_any_call(import_job_id=job1.pk) run.assert_any_call(import_job_id=job2.pk) def test_import_job_viewset_get_queryset_upload_filters_user( - factories, logged_in_api_client): + factories, logged_in_api_client +): logged_in_api_client.user.permission_upload = True logged_in_api_client.user.save() - job = factories['music.ImportJob']() - url = reverse('api:v1:import-jobs-list') + factories["music.ImportJob"]() + url = reverse("api:v1:import-jobs-list") response = logged_in_api_client.get(url) - assert response.data['count'] == 0 + assert response.data["count"] == 0 def test_import_batch_viewset_get_queryset_upload_filters_user( - factories, logged_in_api_client): + factories, logged_in_api_client +): logged_in_api_client.user.permission_upload = True logged_in_api_client.user.save() - job = factories['music.ImportBatch']() - url = reverse('api:v1:import-batches-list') + factories["music.ImportBatch"]() + url = reverse("api:v1:import-batches-list") response = logged_in_api_client.get(url) - assert response.data['count'] == 0 + assert response.data["count"] == 0 diff --git a/api/tests/music/test_works.py b/api/tests/music/test_works.py index 13f6447be..96b537ca2 100644 --- a/api/tests/music/test_works.py +++ b/api/tests/music/test_works.py @@ -1,23 +1,18 @@ -import json -from django.urls import reverse - from funkwhale_api.music import models -from funkwhale_api.musicbrainz import api -from funkwhale_api.music import serializers def test_can_import_work(factories, mocker, works): mocker.patch( - 'funkwhale_api.musicbrainz.api.works.get', - return_value=works['get']['chop_suey']) - recording = factories['music.Track']( - mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') - mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' + "funkwhale_api.musicbrainz.api.works.get", + return_value=works["get"]["chop_suey"], + ) + recording = factories["music.Track"](mbid="07ca77cf-f513-4e9c-b190-d7e24bbad448") + mbid = "e2ecabc4-1b9d-30b2-8f30-3596ec423dc5" work = models.Work.create_from_api(id=mbid) - assert work.title == 'Chop Suey!' - assert work.nature == 'song' - assert work.language == 'eng' + assert work.title == "Chop Suey!" + assert work.nature == "song" + assert work.language == "eng" assert work.mbid == mbid # a imported work should also be linked to corresponding recordings @@ -28,23 +23,25 @@ def test_can_import_work(factories, mocker, works): def test_can_get_work_from_recording(factories, mocker, works, tracks): mocker.patch( - 'funkwhale_api.musicbrainz.api.works.get', - return_value=works['get']['chop_suey']) + "funkwhale_api.musicbrainz.api.works.get", + return_value=works["get"]["chop_suey"], + ) mocker.patch( - 'funkwhale_api.musicbrainz.api.recordings.get', - return_value=tracks['get']['chop_suey']) - recording = factories['music.Track']( - work=None, - mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448') - mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' + "funkwhale_api.musicbrainz.api.recordings.get", + return_value=tracks["get"]["chop_suey"], + ) + recording = factories["music.Track"]( + work=None, mbid="07ca77cf-f513-4e9c-b190-d7e24bbad448" + ) + mbid = "e2ecabc4-1b9d-30b2-8f30-3596ec423dc5" - assert recording.work == None + assert recording.work is None work = recording.get_work() - assert work.title == 'Chop Suey!' - assert work.nature == 'song' - assert work.language == 'eng' + assert work.title == "Chop Suey!" + assert work.nature == "song" + assert work.language == "eng" assert work.mbid == mbid recording.refresh_from_db() @@ -53,11 +50,12 @@ def test_can_get_work_from_recording(factories, mocker, works, tracks): def test_works_import_lyrics_if_any(db, mocker, works): mocker.patch( - 'funkwhale_api.musicbrainz.api.works.get', - return_value=works['get']['chop_suey']) - mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5' + "funkwhale_api.musicbrainz.api.works.get", + return_value=works["get"]["chop_suey"], + ) + mbid = "e2ecabc4-1b9d-30b2-8f30-3596ec423dc5" work = models.Work.create_from_api(id=mbid) - lyrics = models.Lyrics.objects.latest('id') + lyrics = models.Lyrics.objects.latest("id") assert lyrics.work == work - assert lyrics.url == 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!' + assert lyrics.url == "http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!" diff --git a/api/tests/musicbrainz/conftest.py b/api/tests/musicbrainz/conftest.py index 505d6e553..3e3ebfa48 100644 --- a/api/tests/musicbrainz/conftest.py +++ b/api/tests/musicbrainz/conftest.py @@ -1,33 +1,28 @@ import pytest -_artists = {'search': {}, 'get': {}} -_artists['search']['lost fingers'] = { - 'artist-count': 696, - 'artist-list': [ +_artists = {"search": {}, "get": {}} +_artists["search"]["lost fingers"] = { + "artist-count": 696, + "artist-list": [ { - 'country': 'CA', - 'sort-name': 'Lost Fingers, The', - 'id': 'ac16bbc0-aded-4477-a3c3-1d81693d58c9', - 'type': 'Group', - 'life-span': { - 'ended': 'false', - 'begin': '2008' + "country": "CA", + "sort-name": "Lost Fingers, The", + "id": "ac16bbc0-aded-4477-a3c3-1d81693d58c9", + "type": "Group", + "life-span": {"ended": "false", "begin": "2008"}, + "area": { + "sort-name": "Canada", + "id": "71bbafaa-e825-3e15-8ca9-017dcad1748b", + "name": "Canada", }, - 'area': { - 'sort-name': 'Canada', - 'id': '71bbafaa-e825-3e15-8ca9-017dcad1748b', - 'name': 'Canada' - }, - 'ext:score': '100', - 'name': 'The Lost Fingers' - }, - ] + "ext:score": "100", + "name": "The Lost Fingers", + } + ], } -_artists['get']['lost fingers'] = { +_artists["get"]["lost fingers"] = { "artist": { - "life-span": { - "begin": "2008" - }, + "life-span": {"begin": "2008"}, "type": "Group", "id": "ac16bbc0-aded-4477-a3c3-1d81693d58c9", "release-group-count": 8, @@ -38,137 +33,135 @@ _artists['get']['lost fingers'] = { "first-release-date": "2010", "type": "Album", "id": "03d3f1d4-e2b0-40d3-8314-05f1896e93a0", - "primary-type": "Album" + "primary-type": "Album", }, { "title": "Gitan Kameleon", "first-release-date": "2011-11-11", "type": "Album", "id": "243c0cd2-2492-4f5d-bf37-c7c76bed05b7", - "primary-type": "Album" + "primary-type": "Album", }, { "title": "Pump Up the Jam \u2013 Do Not Cover, Pt. 3", "first-release-date": "2014-03-17", "type": "Single", "id": "4429befd-ff45-48eb-a8f4-cdf7bf007f3f", - "primary-type": "Single" + "primary-type": "Single", }, { "title": "La Marquise", "first-release-date": "2012-03-27", "type": "Album", "id": "4dab4b96-0a6b-4507-a31e-2189e3e7bad1", - "primary-type": "Album" + "primary-type": "Album", }, { "title": "Christmas Caravan", "first-release-date": "2016-11-11", "type": "Album", "id": "ca0a506d-6ba9-47c3-a712-de5ce9ae6b1f", - "primary-type": "Album" + "primary-type": "Album", }, { "title": "Rendez-vous rose", "first-release-date": "2009-06-16", "type": "Album", "id": "d002f1a8-5890-4188-be58-1caadbbd767f", - "primary-type": "Album" + "primary-type": "Album", }, { "title": "Wonders of the World", "first-release-date": "2014-05-06", "type": "Album", "id": "eeb644c2-5000-42fb-b959-e5e9cc2901c5", - "primary-type": "Album" + "primary-type": "Album", }, { "title": "Lost in the 80s", "first-release-date": "2008-05-06", "type": "Album", "id": "f04ed607-11b7-3843-957e-503ecdd485d1", - "primary-type": "Album" - } + "primary-type": "Album", + }, ], "area": { - "iso-3166-1-code-list": [ - "CA" - ], + "iso-3166-1-code-list": ["CA"], "name": "Canada", "id": "71bbafaa-e825-3e15-8ca9-017dcad1748b", - "sort-name": "Canada" + "sort-name": "Canada", }, "sort-name": "Lost Fingers, The", - "country": "CA" + "country": "CA", } } -_release_groups = {'browse': {}} -_release_groups['browse']["lost fingers"] = { +_release_groups = {"browse": {}} +_release_groups["browse"]["lost fingers"] = { "release-group-list": [ { "first-release-date": "2010", "type": "Album", "primary-type": "Album", "title": "Gypsy Kameleon", - "id": "03d3f1d4-e2b0-40d3-8314-05f1896e93a0" + "id": "03d3f1d4-e2b0-40d3-8314-05f1896e93a0", }, { "first-release-date": "2011-11-11", "type": "Album", "primary-type": "Album", "title": "Gitan Kameleon", - "id": "243c0cd2-2492-4f5d-bf37-c7c76bed05b7" + "id": "243c0cd2-2492-4f5d-bf37-c7c76bed05b7", }, { "first-release-date": "2014-03-17", "type": "Single", "primary-type": "Single", "title": "Pump Up the Jam \u2013 Do Not Cover, Pt. 3", - "id": "4429befd-ff45-48eb-a8f4-cdf7bf007f3f" + "id": "4429befd-ff45-48eb-a8f4-cdf7bf007f3f", }, { "first-release-date": "2012-03-27", "type": "Album", "primary-type": "Album", "title": "La Marquise", - "id": "4dab4b96-0a6b-4507-a31e-2189e3e7bad1" + "id": "4dab4b96-0a6b-4507-a31e-2189e3e7bad1", }, { "first-release-date": "2016-11-11", "type": "Album", "primary-type": "Album", "title": "Christmas Caravan", - "id": "ca0a506d-6ba9-47c3-a712-de5ce9ae6b1f" + "id": "ca0a506d-6ba9-47c3-a712-de5ce9ae6b1f", }, { "first-release-date": "2009-06-16", "type": "Album", "primary-type": "Album", "title": "Rendez-vous rose", - "id": "d002f1a8-5890-4188-be58-1caadbbd767f" + "id": "d002f1a8-5890-4188-be58-1caadbbd767f", }, { "first-release-date": "2014-05-06", "type": "Album", "primary-type": "Album", "title": "Wonders of the World", - "id": "eeb644c2-5000-42fb-b959-e5e9cc2901c5" + "id": "eeb644c2-5000-42fb-b959-e5e9cc2901c5", }, { "first-release-date": "2008-05-06", "type": "Album", "primary-type": "Album", "title": "Lost in the 80s", - "id": "f04ed607-11b7-3843-957e-503ecdd485d1" - } + "id": "f04ed607-11b7-3843-957e-503ecdd485d1", + }, ], - "release-group-count": 8 + "release-group-count": 8, } -_recordings = {'search': {}, 'get': {}} -_recordings['search']['brontide matador'] = { +_recordings = {"search": {}, "get": {}} +_recordings["search"]["brontide matador"] = { "recording-count": 1044, "recording-list": [ { @@ -184,9 +177,9 @@ _recordings['search']['brontide matador'] = { "name": "United Kingdom", "sort-name": "United Kingdom", "id": "8a754a16-0027-3a29-b6d7-2b40ea0481ed", - "iso-3166-1-code-list": ["GB"] + "iso-3166-1-code-list": ["GB"], }, - "date": "2011-05-30" + "date": "2011-05-30", } ], "country": "GB", @@ -196,7 +189,7 @@ _recordings['search']['brontide matador'] = { "release-group": { "type": "Album", "id": "113ab958-cfb8-4782-99af-639d4d9eae8d", - "primary-type": "Album" + "primary-type": "Album", }, "medium-list": [ { @@ -206,22 +199,24 @@ _recordings['search']['brontide matador'] = { "track_or_recording_length": "366280", "id": "fe506782-a5cb-3d89-9b3e-86287be05768", "length": "366280", - "title": "Matador", "number": "1" + "title": "Matador", + "number": "1", } ], "position": "1", - "track-count": 8 + "track-count": 8, } - ] - }, - ] + ], + } + ], } - ] + ], } -_releases = {'search': {}, 'get': {}, 'browse': {}} -_releases['search']['brontide matador'] = { - "release-count": 116, "release-list": [ +_releases = {"search": {}, "get": {}, "browse": {}} +_releases["search"]["brontide matador"] = { + "release-count": 116, + "release-list": [ { "ext:score": "100", "date": "2009-04-02", @@ -231,16 +226,16 @@ _releases['search']['brontide matador'] = { "name": "[Worldwide]", "sort-name": "[Worldwide]", "id": "525d4e18-3d00-31b9-a58b-a146a916de8f", - "iso-3166-1-code-list": ["XW"] + "iso-3166-1-code-list": ["XW"], }, - "date": "2009-04-02" + "date": "2009-04-02", } ], "label-info-list": [ { "label": { "name": "Holy Roar", - "id": "6e940f35-961d-4ac3-bc2a-569fc211c2e3" + "id": "6e940f35-961d-4ac3-bc2a-569fc211c2e3", } } ], @@ -251,7 +246,7 @@ _releases['search']['brontide matador'] = { "artist": { "name": "Brontide", "sort-name": "Brontide", - "id": "2179fbd2-3c88-4b94-a778-eb3daf1e81a1" + "id": "2179fbd2-3c88-4b94-a778-eb3daf1e81a1", } } ], @@ -265,7 +260,7 @@ _releases['search']['brontide matador'] = { "type": "EP", "secondary-type-list": ["Demo"], "id": "b9207129-2d03-4a68-8a53-3c46fe7d2810", - "primary-type": "EP" + "primary-type": "EP", }, "medium-list": [ { @@ -273,28 +268,22 @@ _releases['search']['brontide matador'] = { "format": "Digital Media", "disc-count": 0, "track-count": 3, - "track-list": [] + "track-list": [], } ], "medium-count": 1, - "text-representation": { - "script": "Latn", - "language": "eng" - } - }, - ] + "text-representation": {"script": "Latn", "language": "eng"}, + } + ], } -_releases['browse']['Lost in the 80s'] = { +_releases["browse"]["Lost in the 80s"] = { "release-count": 3, "release-list": [ { "quality": "normal", "status": "Official", - "text-representation": { - "script": "Latn", - "language": "eng" - }, + "text-representation": {"script": "Latn", "language": "eng"}, "title": "Lost in the 80s", "date": "2008-05-06", "release-event-count": 1, @@ -304,14 +293,12 @@ _releases['browse']['Lost in the 80s'] = { "release-event-list": [ { "area": { - "iso-3166-1-code-list": [ - "CA" - ], + "iso-3166-1-code-list": ["CA"], "id": "71bbafaa-e825-3e15-8ca9-017dcad1748b", "name": "Canada", - "sort-name": "Canada" + "sort-name": "Canada", }, - "date": "2008-05-06" + "date": "2008-05-06", } ], "country": "CA", @@ -319,7 +306,7 @@ _releases['browse']['Lost in the 80s'] = { "back": "false", "artwork": "false", "front": "false", - "count": "0" + "count": "0", }, "medium-list": [ { @@ -333,11 +320,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "2e0dbf37-65af-4408-8def-7b0b3cb8426b", "length": "228000", - "title": "Pump Up the Jam" + "title": "Pump Up the Jam", }, "track_or_recording_length": "228000", "position": "1", - "number": "1" + "number": "1", }, { "id": "01a8cf99-2170-3d3f-96ef-5e4ef7a015a4", @@ -345,11 +332,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "57017e2e-625d-4e7b-a445-47cdb0224dd2", "length": "231000", - "title": "You Give Love a Bad Name" + "title": "You Give Love a Bad Name", }, "track_or_recording_length": "231000", "position": "2", - "number": "2" + "number": "2", }, { "id": "375a7ce7-5a41-3fbf-9809-96d491401034", @@ -357,11 +344,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "a948672b-b42d-44a5-89b0-7e9ab6a7e11d", "length": "189000", - "title": "You Shook Me All Night Long" + "title": "You Shook Me All Night Long", }, "track_or_recording_length": "189000", "position": "3", - "number": "3" + "number": "3", }, { "id": "ed7d823e-76da-31be-82a8-770288e27d32", @@ -369,11 +356,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "6e097e31-f37b-4fae-8ad0-ada57f3091a7", "length": "253000", - "title": "Incognito" + "title": "Incognito", }, "track_or_recording_length": "253000", "position": "4", - "number": "4" + "number": "4", }, { "id": "76ac8c77-6a99-34d9-ae4d-be8f056d50e0", @@ -381,11 +368,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "faa922e6-e834-44ee-8125-79e640a690e3", "length": "221000", - "title": "Touch Me" + "title": "Touch Me", }, "track_or_recording_length": "221000", "position": "5", - "number": "5" + "number": "5", }, { "id": "d0a87409-2be6-3ab7-8526-4313e7134be1", @@ -393,11 +380,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "02da8148-60d8-4c79-ab31-8d90d233d711", "length": "228000", - "title": "Part-Time Lover" + "title": "Part-Time Lover", }, "track_or_recording_length": "228000", "position": "6", - "number": "6" + "number": "6", }, { "id": "02c5384b-5ca9-38e9-8b7c-c08dce608deb", @@ -405,11 +392,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "40085704-d6ab-44f6-a4d8-b27c9ca25b31", "length": "248000", - "title": "Fresh" + "title": "Fresh", }, "track_or_recording_length": "248000", "position": "7", - "number": "7" + "number": "7", }, { "id": "ab389542-53d5-346a-b168-1d915ecf0ef6", @@ -417,11 +404,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "77edd338-eeaf-4157-9e2a-5cc3bcee8abd", "length": "257000", - "title": "Billie Jean" + "title": "Billie Jean", }, "track_or_recording_length": "257000", "position": "8", - "number": "8" + "number": "8", }, { "id": "6d9e722b-7408-350e-bb7c-2de1e329ae84", @@ -429,11 +416,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "040aaffa-7206-40ff-9930-469413fe2420", "length": "293000", - "title": "Careless Whisper" + "title": "Careless Whisper", }, "track_or_recording_length": "293000", "position": "9", - "number": "9" + "number": "9", }, { "id": "63b4e67c-7536-3cd0-8c47-0310c1e40866", @@ -441,11 +428,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "054942f0-4c0f-4e92-a606-d590976b1cff", "length": "211000", - "title": "Tainted Love" + "title": "Tainted Love", }, "track_or_recording_length": "211000", "position": "10", - "number": "10" + "number": "10", }, { "id": "a07f4ca3-dbf0-3337-a247-afcd0509334a", @@ -453,11 +440,11 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "8023b5ad-649a-4c67-b7a2-e12358606f6e", "length": "245000", - "title": "Straight Up" + "title": "Straight Up", }, "track_or_recording_length": "245000", "position": "11", - "number": "11" + "number": "11", }, { "id": "73d47f16-b18d-36ff-b0bb-1fa1fd32ebf7", @@ -465,18 +452,18 @@ _releases['browse']['Lost in the 80s'] = { "recording": { "id": "95a8c8a1-fcb6-4cbb-a853-be86d816b357", "length": "322000", - "title": "Black Velvet" + "title": "Black Velvet", }, "track_or_recording_length": "322000", "position": "12", - "number": "12" - } - ] + "number": "12", + }, + ], } ], - "asin": "B0017M8YTO" - }, - ] + "asin": "B0017M8YTO", + } + ], } diff --git a/api/tests/musicbrainz/test_api.py b/api/tests/musicbrainz/test_api.py index fdd1dbdb0..0fdaf7ab6 100644 --- a/api/tests/musicbrainz/test_api.py +++ b/api/tests/musicbrainz/test_api.py @@ -1,92 +1,95 @@ -import json from django.urls import reverse -from funkwhale_api.musicbrainz import api - - def test_can_search_recording_in_musicbrainz_api( - recordings, db, mocker, logged_in_api_client): + recordings, db, mocker, logged_in_api_client +): mocker.patch( - 'funkwhale_api.musicbrainz.api.recordings.search', - return_value=recordings['search']['brontide matador']) - query = 'brontide matador' - url = reverse('api:v1:providers:musicbrainz:search-recordings') - expected = recordings['search']['brontide matador'] - response = logged_in_api_client.get(url, data={'query': query}) + "funkwhale_api.musicbrainz.api.recordings.search", + return_value=recordings["search"]["brontide matador"], + ) + query = "brontide matador" + url = reverse("api:v1:providers:musicbrainz:search-recordings") + expected = recordings["search"]["brontide matador"] + response = logged_in_api_client.get(url, data={"query": query}) assert expected == response.data -def test_can_search_release_in_musicbrainz_api(releases, db, mocker, logged_in_api_client): +def test_can_search_release_in_musicbrainz_api( + releases, db, mocker, logged_in_api_client +): mocker.patch( - 'funkwhale_api.musicbrainz.api.releases.search', - return_value=releases['search']['brontide matador']) - query = 'brontide matador' - url = reverse('api:v1:providers:musicbrainz:search-releases') - expected = releases['search']['brontide matador'] - response = logged_in_api_client.get(url, data={'query': query}) + "funkwhale_api.musicbrainz.api.releases.search", + return_value=releases["search"]["brontide matador"], + ) + query = "brontide matador" + url = reverse("api:v1:providers:musicbrainz:search-releases") + expected = releases["search"]["brontide matador"] + response = logged_in_api_client.get(url, data={"query": query}) assert expected == response.data -def test_can_search_artists_in_musicbrainz_api(artists, db, mocker, logged_in_api_client): +def test_can_search_artists_in_musicbrainz_api( + artists, db, mocker, logged_in_api_client +): mocker.patch( - 'funkwhale_api.musicbrainz.api.artists.search', - return_value=artists['search']['lost fingers']) - query = 'lost fingers' - url = reverse('api:v1:providers:musicbrainz:search-artists') - expected = artists['search']['lost fingers'] - response = logged_in_api_client.get(url, data={'query': query}) + "funkwhale_api.musicbrainz.api.artists.search", + return_value=artists["search"]["lost fingers"], + ) + query = "lost fingers" + url = reverse("api:v1:providers:musicbrainz:search-artists") + expected = artists["search"]["lost fingers"] + response = logged_in_api_client.get(url, data={"query": query}) assert expected == response.data def test_can_get_artist_in_musicbrainz_api(artists, db, mocker, logged_in_api_client): mocker.patch( - 'funkwhale_api.musicbrainz.api.artists.get', - return_value=artists['get']['lost fingers']) - uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9' - url = reverse('api:v1:providers:musicbrainz:artist-detail', kwargs={ - 'uuid': uuid, - }) + "funkwhale_api.musicbrainz.api.artists.get", + return_value=artists["get"]["lost fingers"], + ) + uuid = "ac16bbc0-aded-4477-a3c3-1d81693d58c9" + url = reverse("api:v1:providers:musicbrainz:artist-detail", kwargs={"uuid": uuid}) response = logged_in_api_client.get(url) - expected = artists['get']['lost fingers'] + expected = artists["get"]["lost fingers"] assert expected == response.data def test_can_broswe_release_group_using_musicbrainz_api( - release_groups, db, mocker, logged_in_api_client): + release_groups, db, mocker, logged_in_api_client +): mocker.patch( - 'funkwhale_api.musicbrainz.api.release_groups.browse', - return_value=release_groups['browse']['lost fingers']) - uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9' + "funkwhale_api.musicbrainz.api.release_groups.browse", + return_value=release_groups["browse"]["lost fingers"], + ) + uuid = "ac16bbc0-aded-4477-a3c3-1d81693d58c9" url = reverse( - 'api:v1:providers:musicbrainz:release-group-browse', - kwargs={ - 'artist_uuid': uuid, - } + "api:v1:providers:musicbrainz:release-group-browse", + kwargs={"artist_uuid": uuid}, ) response = logged_in_api_client.get(url) - expected = release_groups['browse']['lost fingers'] + expected = release_groups["browse"]["lost fingers"] assert expected == response.data def test_can_broswe_releases_using_musicbrainz_api( - releases, db, mocker, logged_in_api_client): + releases, db, mocker, logged_in_api_client +): mocker.patch( - 'funkwhale_api.musicbrainz.api.releases.browse', - return_value=releases['browse']['Lost in the 80s']) - uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1' + "funkwhale_api.musicbrainz.api.releases.browse", + return_value=releases["browse"]["Lost in the 80s"], + ) + uuid = "f04ed607-11b7-3843-957e-503ecdd485d1" url = reverse( - 'api:v1:providers:musicbrainz:release-browse', - kwargs={ - 'release_group_uuid': uuid, - } + "api:v1:providers:musicbrainz:release-browse", + kwargs={"release_group_uuid": uuid}, ) response = logged_in_api_client.get(url) - expected = releases['browse']['Lost in the 80s'] + expected = releases["browse"]["Lost in the 80s"] assert expected == response.data diff --git a/api/tests/musicbrainz/test_cache.py b/api/tests/musicbrainz/test_cache.py index fe0d56773..3a326ff24 100644 --- a/api/tests/musicbrainz/test_cache.py +++ b/api/tests/musicbrainz/test_cache.py @@ -2,12 +2,12 @@ from funkwhale_api.musicbrainz import client def test_can_search_recording_in_musicbrainz_api(mocker): - r = {'hello': 'world'} + r = {"hello": "world"} m = mocker.patch( - 'funkwhale_api.musicbrainz.client._api.search_artists', - return_value=r) - assert client.api.artists.search('test') == r + "funkwhale_api.musicbrainz.client._api.search_artists", return_value=r + ) + assert client.api.artists.search("test") == r # now call from cache - assert client.api.artists.search('test') == r - assert client.api.artists.search('test') == r + assert client.api.artists.search("test") == r + assert client.api.artists.search("test") == r assert m.call_count == 1 diff --git a/api/tests/playlists/test_models.py b/api/tests/playlists/test_models.py index fe5dd40a8..25c40d557 100644 --- a/api/tests/playlists/test_models.py +++ b/api/tests/playlists/test_models.py @@ -1,10 +1,9 @@ import pytest - from rest_framework import exceptions def test_can_insert_plt(factories): - plt = factories['playlists.PlaylistTrack']() + plt = factories["playlists.PlaylistTrack"]() modification_date = plt.playlist.modification_date assert plt.index is None @@ -17,9 +16,8 @@ def test_can_insert_plt(factories): def test_insert_use_last_idx_by_default(factories): - playlist = factories['playlists.Playlist']() - plts = factories['playlists.PlaylistTrack'].create_batch( - size=3, playlist=playlist) + playlist = factories["playlists.Playlist"]() + plts = factories["playlists.PlaylistTrack"].create_batch(size=3, playlist=playlist) for i, plt in enumerate(plts): index = playlist.insert(plt) @@ -28,11 +26,12 @@ def test_insert_use_last_idx_by_default(factories): assert index == i assert plt.index == i + def test_can_insert_at_index(factories): - playlist = factories['playlists.Playlist']() - first = factories['playlists.PlaylistTrack'](playlist=playlist) + playlist = factories["playlists.Playlist"]() + first = factories["playlists.PlaylistTrack"](playlist=playlist) playlist.insert(first) - new_first = factories['playlists.PlaylistTrack'](playlist=playlist) + new_first = factories["playlists.PlaylistTrack"](playlist=playlist) index = playlist.insert(new_first, index=0) first.refresh_from_db() new_first.refresh_from_db() @@ -43,10 +42,10 @@ def test_can_insert_at_index(factories): def test_can_insert_and_move(factories): - playlist = factories['playlists.Playlist']() - first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0) - second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1) - third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2) + playlist = factories["playlists.Playlist"]() + first = factories["playlists.PlaylistTrack"](playlist=playlist, index=0) + second = factories["playlists.PlaylistTrack"](playlist=playlist, index=1) + third = factories["playlists.PlaylistTrack"](playlist=playlist, index=2) playlist.insert(second, index=0) @@ -60,10 +59,10 @@ def test_can_insert_and_move(factories): def test_can_insert_and_move_last_to_0(factories): - playlist = factories['playlists.Playlist']() - first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0) - second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1) - third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2) + playlist = factories["playlists.Playlist"]() + first = factories["playlists.PlaylistTrack"](playlist=playlist, index=0) + second = factories["playlists.PlaylistTrack"](playlist=playlist, index=1) + third = factories["playlists.PlaylistTrack"](playlist=playlist, index=2) playlist.insert(third, index=0) @@ -77,24 +76,24 @@ def test_can_insert_and_move_last_to_0(factories): def test_cannot_insert_at_wrong_index(factories): - plt = factories['playlists.PlaylistTrack']() - new = factories['playlists.PlaylistTrack'](playlist=plt.playlist) + plt = factories["playlists.PlaylistTrack"]() + new = factories["playlists.PlaylistTrack"](playlist=plt.playlist) with pytest.raises(exceptions.ValidationError): plt.playlist.insert(new, 2) def test_cannot_insert_at_negative_index(factories): - plt = factories['playlists.PlaylistTrack']() - new = factories['playlists.PlaylistTrack'](playlist=plt.playlist) + plt = factories["playlists.PlaylistTrack"]() + new = factories["playlists.PlaylistTrack"](playlist=plt.playlist) with pytest.raises(exceptions.ValidationError): plt.playlist.insert(new, -1) def test_remove_update_indexes(factories): - playlist = factories['playlists.Playlist']() - first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0) - second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1) - third = factories['playlists.PlaylistTrack'](playlist=playlist, index=2) + playlist = factories["playlists.Playlist"]() + first = factories["playlists.PlaylistTrack"](playlist=playlist, index=0) + second = factories["playlists.PlaylistTrack"](playlist=playlist, index=1) + third = factories["playlists.PlaylistTrack"](playlist=playlist, index=2) second.delete(update_indexes=True) @@ -106,9 +105,9 @@ def test_remove_update_indexes(factories): def test_can_insert_many(factories): - playlist = factories['playlists.Playlist']() - existing = factories['playlists.PlaylistTrack'](playlist=playlist, index=0) - tracks = factories['music.Track'].create_batch(size=3) + playlist = factories["playlists.Playlist"]() + factories["playlists.PlaylistTrack"](playlist=playlist, index=0) + tracks = factories["music.Track"].create_batch(size=3) plts = playlist.insert_many(tracks) for i, plt in enumerate(plts): assert plt.index == i + 1 @@ -117,10 +116,9 @@ def test_can_insert_many(factories): def test_insert_many_honor_max_tracks(preferences, factories): - preferences['playlists__max_tracks'] = 4 - playlist = factories['playlists.Playlist']() - plts = factories['playlists.PlaylistTrack'].create_batch( - size=2, playlist=playlist) - track = factories['music.Track']() + preferences["playlists__max_tracks"] = 4 + playlist = factories["playlists.Playlist"]() + factories["playlists.PlaylistTrack"].create_batch(size=2, playlist=playlist) + track = factories["music.Track"]() with pytest.raises(exceptions.ValidationError): playlist.insert_many([track, track, track]) diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index 908c1c796..677288070 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -1,31 +1,26 @@ -from funkwhale_api.playlists import models -from funkwhale_api.playlists import serializers +from funkwhale_api.playlists import models, serializers def test_cannot_max_500_tracks_per_playlist(factories, preferences): - preferences['playlists__max_tracks'] = 2 - playlist = factories['playlists.Playlist']() - plts = factories['playlists.PlaylistTrack'].create_batch( - size=2, playlist=playlist) - track = factories['music.Track']() - serializer = serializers.PlaylistTrackWriteSerializer(data={ - 'playlist': playlist.pk, - 'track': track.pk, - }) + preferences["playlists__max_tracks"] = 2 + playlist = factories["playlists.Playlist"]() + factories["playlists.PlaylistTrack"].create_batch(size=2, playlist=playlist) + track = factories["music.Track"]() + serializer = serializers.PlaylistTrackWriteSerializer( + data={"playlist": playlist.pk, "track": track.pk} + ) assert serializer.is_valid() is False - assert 'playlist' in serializer.errors + assert "playlist" in serializer.errors def test_create_insert_is_called_when_index_is_None(factories, mocker): - insert = mocker.spy(models.Playlist, 'insert') - playlist = factories['playlists.Playlist']() - track = factories['music.Track']() - serializer = serializers.PlaylistTrackWriteSerializer(data={ - 'playlist': playlist.pk, - 'track': track.pk, - 'index': None, - }) + insert = mocker.spy(models.Playlist, "insert") + playlist = factories["playlists.Playlist"]() + track = factories["music.Track"]() + serializer = serializers.PlaylistTrackWriteSerializer( + data={"playlist": playlist.pk, "track": track.pk, "index": None} + ) assert serializer.is_valid() is True plt = serializer.save() @@ -34,16 +29,14 @@ def test_create_insert_is_called_when_index_is_None(factories, mocker): def test_create_insert_is_called_when_index_is_provided(factories, mocker): - playlist = factories['playlists.Playlist']() - first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0) - insert = mocker.spy(models.Playlist, 'insert') - factories['playlists.Playlist']() - track = factories['music.Track']() - serializer = serializers.PlaylistTrackWriteSerializer(data={ - 'playlist': playlist.pk, - 'track': track.pk, - 'index': 0, - }) + playlist = factories["playlists.Playlist"]() + first = factories["playlists.PlaylistTrack"](playlist=playlist, index=0) + insert = mocker.spy(models.Playlist, "insert") + factories["playlists.Playlist"]() + track = factories["music.Track"]() + serializer = serializers.PlaylistTrackWriteSerializer( + data={"playlist": playlist.pk, "track": track.pk, "index": 0} + ) assert serializer.is_valid() is True plt = serializer.save() @@ -54,17 +47,15 @@ def test_create_insert_is_called_when_index_is_provided(factories, mocker): def test_update_insert_is_called_when_index_is_provided(factories, mocker): - playlist = factories['playlists.Playlist']() - first = factories['playlists.PlaylistTrack'](playlist=playlist, index=0) - second = factories['playlists.PlaylistTrack'](playlist=playlist, index=1) - insert = mocker.spy(models.Playlist, 'insert') - factories['playlists.Playlist']() - track = factories['music.Track']() - serializer = serializers.PlaylistTrackWriteSerializer(second, data={ - 'playlist': playlist.pk, - 'track': second.track.pk, - 'index': 0, - }) + playlist = factories["playlists.Playlist"]() + first = factories["playlists.PlaylistTrack"](playlist=playlist, index=0) + second = factories["playlists.PlaylistTrack"](playlist=playlist, index=1) + insert = mocker.spy(models.Playlist, "insert") + factories["playlists.Playlist"]() + factories["music.Track"]() + serializer = serializers.PlaylistTrackWriteSerializer( + second, data={"playlist": playlist.pk, "track": second.track.pk, "index": 0} + ) assert serializer.is_valid() is True plt = serializer.save() diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py index 44d060821..e7b47c7a2 100644 --- a/api/tests/playlists/test_views.py +++ b/api/tests/playlists/test_views.py @@ -1,72 +1,59 @@ -import json import pytest - from django.urls import reverse -from django.core.exceptions import ValidationError -from django.utils import timezone -from funkwhale_api.playlists import models -from funkwhale_api.playlists import serializers +from funkwhale_api.playlists import models, serializers def test_can_create_playlist_via_api(logged_in_api_client): - url = reverse('api:v1:playlists-list') - data = { - 'name': 'test', - 'privacy_level': 'everyone' - } + url = reverse("api:v1:playlists-list") + data = {"name": "test", "privacy_level": "everyone"} - response = logged_in_api_client.post(url, data) + logged_in_api_client.post(url, data) - playlist = logged_in_api_client.user.playlists.latest('id') - assert playlist.name == 'test' - assert playlist.privacy_level == 'everyone' + playlist = logged_in_api_client.user.playlists.latest("id") + assert playlist.name == "test" + assert playlist.privacy_level == "everyone" def test_serializer_includes_tracks_count(factories, logged_in_api_client): - playlist = factories['playlists.Playlist']() - plt = factories['playlists.PlaylistTrack'](playlist=playlist) + playlist = factories["playlists.Playlist"]() + factories["playlists.PlaylistTrack"](playlist=playlist) - url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk}) + url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) response = logged_in_api_client.get(url) - assert response.data['tracks_count'] == 1 + assert response.data["tracks_count"] == 1 def test_playlist_inherits_user_privacy(logged_in_api_client): - url = reverse('api:v1:playlists-list') + url = reverse("api:v1:playlists-list") user = logged_in_api_client.user - user.privacy_level = 'me' + user.privacy_level = "me" user.save() - data = { - 'name': 'test', - } + data = {"name": "test"} - response = logged_in_api_client.post(url, data) - playlist = user.playlists.latest('id') + logged_in_api_client.post(url, data) + playlist = user.playlists.latest("id") assert playlist.privacy_level == user.privacy_level def test_can_add_playlist_track_via_api(factories, logged_in_api_client): - tracks = factories['music.Track'].create_batch(5) - playlist = factories['playlists.Playlist'](user=logged_in_api_client.user) - url = reverse('api:v1:playlist-tracks-list') - data = { - 'playlist': playlist.pk, - 'track': tracks[0].pk - } + tracks = factories["music.Track"].create_batch(5) + playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + url = reverse("api:v1:playlist-tracks-list") + data = {"playlist": playlist.pk, "track": tracks[0].pk} response = logged_in_api_client.post(url, data) assert response.status_code == 201 - plts = logged_in_api_client.user.playlists.latest('id').playlist_tracks.all() + plts = logged_in_api_client.user.playlists.latest("id").playlist_tracks.all() assert plts.first().track == tracks[0] -@pytest.mark.parametrize('name,method', [ - ('api:v1:playlist-tracks-list', 'post'), - ('api:v1:playlists-list', 'post'), -]) +@pytest.mark.parametrize( + "name,method", + [("api:v1:playlist-tracks-list", "post"), ("api:v1:playlists-list", "post")], +) def test_url_requires_login(name, method, factories, api_client): url = reverse(name) @@ -75,29 +62,24 @@ def test_url_requires_login(name, method, factories, api_client): assert response.status_code == 401 -def test_only_can_add_track_on_own_playlist_via_api( - factories, logged_in_api_client): - track = factories['music.Track']() - playlist = factories['playlists.Playlist']() - url = reverse('api:v1:playlist-tracks-list') - data = { - 'playlist': playlist.pk, - 'track': track.pk - } +def test_only_can_add_track_on_own_playlist_via_api(factories, logged_in_api_client): + track = factories["music.Track"]() + playlist = factories["playlists.Playlist"]() + url = reverse("api:v1:playlist-tracks-list") + data = {"playlist": playlist.pk, "track": track.pk} response = logged_in_api_client.post(url, data) assert response.status_code == 400 assert playlist.playlist_tracks.count() == 0 -def test_deleting_plt_updates_indexes( - mocker, factories, logged_in_api_client): - remove = mocker.spy(models.Playlist, 'remove') - track = factories['music.Track']() - plt = factories['playlists.PlaylistTrack']( - index=0, - playlist__user=logged_in_api_client.user) - url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk}) +def test_deleting_plt_updates_indexes(mocker, factories, logged_in_api_client): + remove = mocker.spy(models.Playlist, "remove") + factories["music.Track"]() + plt = factories["playlists.PlaylistTrack"]( + index=0, playlist__user=logged_in_api_client.user + ) + url = reverse("api:v1:playlist-tracks-detail", kwargs={"pk": plt.pk}) response = logged_in_api_client.delete(url) @@ -105,97 +87,93 @@ def test_deleting_plt_updates_indexes( remove.assert_called_once_with(plt.playlist, 0) -@pytest.mark.parametrize('level', ['instance', 'me', 'followers']) +@pytest.mark.parametrize("level", ["instance", "me", "followers"]) def test_playlist_privacy_respected_in_list_anon( - preferences, level, factories, api_client): - preferences['common__api_authentication_required'] = False - factories['playlists.Playlist'](privacy_level=level) - url = reverse('api:v1:playlists-list') + preferences, level, factories, api_client +): + preferences["common__api_authentication_required"] = False + factories["playlists.Playlist"](privacy_level=level) + url = reverse("api:v1:playlists-list") response = api_client.get(url) - assert response.data['count'] == 0 + assert response.data["count"] == 0 -@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE']) +@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"]) def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client): - playlist = factories['playlists.Playlist']() - url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk}) + playlist = factories["playlists.Playlist"]() + url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) response = getattr(logged_in_api_client, method.lower())(url) assert response.status_code == 404 -@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE']) -def test_only_owner_can_edit_playlist_track( - method, factories, logged_in_api_client): - plt = factories['playlists.PlaylistTrack']() - url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk}) +@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"]) +def test_only_owner_can_edit_playlist_track(method, factories, logged_in_api_client): + plt = factories["playlists.PlaylistTrack"]() + url = reverse("api:v1:playlist-tracks-detail", kwargs={"pk": plt.pk}) response = getattr(logged_in_api_client, method.lower())(url) assert response.status_code == 404 -@pytest.mark.parametrize('level', ['instance', 'me', 'followers']) +@pytest.mark.parametrize("level", ["instance", "me", "followers"]) def test_playlist_track_privacy_respected_in_list_anon( - level, factories, api_client, preferences): - preferences['common__api_authentication_required'] = False - factories['playlists.PlaylistTrack'](playlist__privacy_level=level) - url = reverse('api:v1:playlist-tracks-list') + level, factories, api_client, preferences +): + preferences["common__api_authentication_required"] = False + factories["playlists.PlaylistTrack"](playlist__privacy_level=level) + url = reverse("api:v1:playlist-tracks-list") response = api_client.get(url) - assert response.data['count'] == 0 + assert response.data["count"] == 0 -@pytest.mark.parametrize('level', ['instance', 'me', 'followers']) -def test_can_list_tracks_from_playlist( - level, factories, logged_in_api_client): - plt = factories['playlists.PlaylistTrack']( - playlist__user=logged_in_api_client.user) - url = reverse('api:v1:playlists-tracks', kwargs={'pk': plt.playlist.pk}) +@pytest.mark.parametrize("level", ["instance", "me", "followers"]) +def test_can_list_tracks_from_playlist(level, factories, logged_in_api_client): + plt = factories["playlists.PlaylistTrack"](playlist__user=logged_in_api_client.user) + url = reverse("api:v1:playlists-tracks", kwargs={"pk": plt.playlist.pk}) response = logged_in_api_client.get(url) serialized_plt = serializers.PlaylistTrackSerializer(plt).data - assert response.data['count'] == 1 - assert response.data['results'][0] == serialized_plt + assert response.data["count"] == 1 + assert response.data["results"][0] == serialized_plt def test_can_add_multiple_tracks_at_once_via_api( - factories, mocker, logged_in_api_client): - playlist = factories['playlists.Playlist'](user=logged_in_api_client.user) - tracks = factories['music.Track'].create_batch(size=5) + factories, mocker, logged_in_api_client +): + playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + tracks = factories["music.Track"].create_batch(size=5) track_ids = [t.id for t in tracks] - mocker.spy(playlist, 'insert_many') - url = reverse('api:v1:playlists-add', kwargs={'pk': playlist.pk}) - response = logged_in_api_client.post(url, {'tracks': track_ids}) + mocker.spy(playlist, "insert_many") + url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk}) + response = logged_in_api_client.post(url, {"tracks": track_ids}) assert response.status_code == 201 assert playlist.playlist_tracks.count() == len(track_ids) - for plt in playlist.playlist_tracks.order_by('index'): - assert response.data['results'][plt.index]['id'] == plt.id + for plt in playlist.playlist_tracks.order_by("index"): + assert response.data["results"][plt.index]["id"] == plt.id assert plt.track == tracks[plt.index] -def test_can_clear_playlist_from_api( - factories, mocker, logged_in_api_client): - playlist = factories['playlists.Playlist'](user=logged_in_api_client.user) - plts = factories['playlists.PlaylistTrack'].create_batch( - size=5, playlist=playlist) - url = reverse('api:v1:playlists-clear', kwargs={'pk': playlist.pk}) +def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client): + playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist) + url = reverse("api:v1:playlists-clear", kwargs={"pk": playlist.pk}) response = logged_in_api_client.delete(url) assert response.status_code == 204 assert playlist.playlist_tracks.count() == 0 -def test_update_playlist_from_api( - factories, mocker, logged_in_api_client): - playlist = factories['playlists.Playlist'](user=logged_in_api_client.user) - plts = factories['playlists.PlaylistTrack'].create_batch( - size=5, playlist=playlist) - url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk}) - response = logged_in_api_client.patch(url, {'name': 'test'}) +def test_update_playlist_from_api(factories, mocker, logged_in_api_client): + playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist) + url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk}) + response = logged_in_api_client.patch(url, {"name": "test"}) playlist.refresh_from_db() assert response.status_code == 200 - assert response.data['user']['username'] == playlist.user.username + assert response.data["user"]["username"] == playlist.user.username diff --git a/api/tests/radios/test_api.py b/api/tests/radios/test_api.py index 66bf6052d..0ddebe387 100644 --- a/api/tests/radios/test_api.py +++ b/api/tests/radios/test_api.py @@ -1,159 +1,131 @@ -import json -import pytest - from django.urls import reverse from funkwhale_api.music.serializers import TrackSerializer -from funkwhale_api.radios import filters -from funkwhale_api.radios import serializers +from funkwhale_api.radios import filters, serializers -def test_can_list_config_options(logged_in_client): - url = reverse('api:v1:radios:radios-filters') - response = logged_in_client.get(url) +def test_can_list_config_options(logged_in_api_client): + url = reverse("api:v1:radios:radios-filters") + response = logged_in_api_client.get(url) assert response.status_code == 200 - payload = json.loads(response.content.decode('utf-8')) + payload = response.data expected = [f for f in filters.registry.values() if f.expose_in_api] assert len(payload) == len(expected) -def test_can_validate_config(logged_in_client, factories): - artist1 = factories['music.Artist']() - artist2 = factories['music.Artist']() - factories['music.Track'].create_batch(3, artist=artist1) - factories['music.Track'].create_batch(3, artist=artist2) - candidates = artist1.tracks.order_by('pk') - f = { - 'filters': [ - {'type': 'artist', 'ids': [artist1.pk]} - ] - } - url = reverse('api:v1:radios:radios-validate') - response = logged_in_client.post( - url, - json.dumps(f), - content_type="application/json") +def test_can_validate_config(logged_in_api_client, factories): + artist1 = factories["music.Artist"]() + artist2 = factories["music.Artist"]() + factories["music.Track"].create_batch(3, artist=artist1) + factories["music.Track"].create_batch(3, artist=artist2) + candidates = artist1.tracks.order_by("pk") + f = {"filters": [{"type": "artist", "ids": [artist1.pk]}]} + url = reverse("api:v1:radios:radios-validate") + response = logged_in_api_client.post(url, f, format="json") assert response.status_code == 200 - payload = json.loads(response.content.decode('utf-8')) + payload = response.data expected = { - 'count': candidates.count(), - 'sample': TrackSerializer(candidates, many=True).data + "count": candidates.count(), + "sample": TrackSerializer(candidates, many=True).data, } - assert payload['filters'][0]['candidates'] == expected - assert payload['filters'][0]['errors'] == [] + assert payload["filters"][0]["candidates"] == expected + assert payload["filters"][0]["errors"] == [] -def test_can_validate_config_with_wrong_config(logged_in_client, factories): - f = { - 'filters': [ - {'type': 'artist', 'ids': [999]} - ] - } - url = reverse('api:v1:radios:radios-validate') - response = logged_in_client.post( - url, - json.dumps(f), - content_type="application/json") +def test_can_validate_config_with_wrong_config(logged_in_api_client, factories): + f = {"filters": [{"type": "artist", "ids": [999]}]} + url = reverse("api:v1:radios:radios-validate") + response = logged_in_api_client.post(url, f, format="json") assert response.status_code == 200 - payload = json.loads(response.content.decode('utf-8')) + payload = response.data - expected = { - 'count': None, - 'sample': None - } - assert payload['filters'][0]['candidates'] == expected - assert len(payload['filters'][0]['errors']) == 1 + expected = {"count": None, "sample": None} + assert payload["filters"][0]["candidates"] == expected + assert len(payload["filters"][0]["errors"]) == 1 -def test_saving_radio_sets_user(logged_in_client, factories): - artist = factories['music.Artist']() - f = { - 'name': 'Test', - 'config': [ - {'type': 'artist', 'ids': [artist.pk]} - ] - } - url = reverse('api:v1:radios:radios-list') - response = logged_in_client.post( - url, - json.dumps(f), - content_type="application/json") +def test_saving_radio_sets_user(logged_in_api_client, factories): + artist = factories["music.Artist"]() + f = {"name": "Test", "config": [{"type": "artist", "ids": [artist.pk]}]} + url = reverse("api:v1:radios:radios-list") + response = logged_in_api_client.post(url, f, format="json") assert response.status_code == 201 - radio = logged_in_client.user.radios.latest('id') - assert radio.name == 'Test' - assert radio.user == logged_in_client.user + radio = logged_in_api_client.user.radios.latest("id") + assert radio.name == "Test" + assert radio.user == logged_in_api_client.user -def test_user_can_detail_his_radio(logged_in_client, factories): - radio = factories['radios.Radio'](user=logged_in_client.user) - url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) - response = logged_in_client.get(url) +def test_user_can_detail_his_radio(logged_in_api_client, factories): + radio = factories["radios.Radio"](user=logged_in_api_client.user) + url = reverse("api:v1:radios:radios-detail", kwargs={"pk": radio.pk}) + response = logged_in_api_client.get(url) assert response.status_code == 200 -def test_user_can_detail_public_radio(logged_in_client, factories): - radio = factories['radios.Radio'](is_public=True) - url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) - response = logged_in_client.get(url) +def test_user_can_detail_public_radio(logged_in_api_client, factories): + radio = factories["radios.Radio"](is_public=True) + url = reverse("api:v1:radios:radios-detail", kwargs={"pk": radio.pk}) + response = logged_in_api_client.get(url) assert response.status_code == 200 -def test_user_cannot_detail_someone_else_radio(logged_in_client, factories): - radio = factories['radios.Radio'](is_public=False) - url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) - response = logged_in_client.get(url) +def test_user_cannot_detail_someone_else_radio(logged_in_api_client, factories): + radio = factories["radios.Radio"](is_public=False) + url = reverse("api:v1:radios:radios-detail", kwargs={"pk": radio.pk}) + response = logged_in_api_client.get(url) assert response.status_code == 404 -def test_user_can_edit_his_radio(logged_in_client, factories): - radio = factories['radios.Radio'](user=logged_in_client.user) - url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) - response = logged_in_client.put( - url, - json.dumps({'name': 'new', 'config': []}), - content_type="application/json") +def test_user_can_edit_his_radio(logged_in_api_client, factories): + radio = factories["radios.Radio"](user=logged_in_api_client.user) + url = reverse("api:v1:radios:radios-detail", kwargs={"pk": radio.pk}) + response = logged_in_api_client.put( + url, {"name": "new", "config": []}, format="json" + ) radio.refresh_from_db() assert response.status_code == 200 - assert radio.name == 'new' + assert radio.name == "new" -def test_user_cannot_edit_someone_else_radio(logged_in_client, factories): - radio = factories['radios.Radio']() - url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk}) - response = logged_in_client.put( - url, - json.dumps({'name': 'new', 'config': []}), - content_type="application/json") +def test_user_cannot_edit_someone_else_radio(logged_in_api_client, factories): + radio = factories["radios.Radio"](is_public=True) + url = reverse("api:v1:radios:radios-detail", kwargs={"pk": radio.pk}) + response = logged_in_api_client.put( + url, {"name": "new", "config": []}, format="json" + ) + + assert response.status_code == 404 + + +def test_user_cannot_delete_someone_else_radio(logged_in_api_client, factories): + radio = factories["radios.Radio"](is_public=True) + url = reverse("api:v1:radios:radios-detail", kwargs={"pk": radio.pk}) + response = logged_in_api_client.delete(url) assert response.status_code == 404 def test_clean_config_is_called_on_serializer_save(mocker, factories): - user = factories['users.User']() - artist = factories['music.Artist']() - data= { - 'name': 'Test', - 'config': [ - {'type': 'artist', 'ids': [artist.pk]} - ] - } - spied = mocker.spy(filters.registry['artist'], 'clean_config') + user = factories["users.User"]() + artist = factories["music.Artist"]() + data = {"name": "Test", "config": [{"type": "artist", "ids": [artist.pk]}]} + spied = mocker.spy(filters.registry["artist"], "clean_config") serializer = serializers.RadioSerializer(data=data) assert serializer.is_valid() instance = serializer.save(user=user) - spied.assert_called_once_with(data['config'][0]) - assert instance.config[0]['names'] == [artist.name] + spied.assert_called_once_with(data["config"][0]) + assert instance.config[0]["names"] == [artist.name] diff --git a/api/tests/radios/test_filters.py b/api/tests/radios/test_filters.py index 27166b4ab..89bb726af 100644 --- a/api/tests/radios/test_filters.py +++ b/api/tests/radios/test_filters.py @@ -1,5 +1,4 @@ import pytest - from django.core.exceptions import ValidationError from funkwhale_api.music.models import Track @@ -8,154 +7,147 @@ from funkwhale_api.radios import filters @filters.registry.register class NoopFilter(filters.RadioFilter): - code = 'noop' + code = "noop" + def get_query(self, candidates, **kwargs): return def test_most_simple_radio_does_not_filter_anything(factories): - tracks = factories['music.Track'].create_batch(3) - radio = factories['radios.Radio'](config=[{'type': 'noop'}]) + factories["music.Track"].create_batch(3) + radio = factories["radios.Radio"](config=[{"type": "noop"}]) assert radio.version == 0 assert radio.get_candidates().count() == 3 - def test_filter_can_use_custom_queryset(factories): - tracks = factories['music.Track'].create_batch(3) + tracks = factories["music.Track"].create_batch(3) candidates = Track.objects.filter(pk=tracks[0].pk) - qs = filters.run([{'type': 'noop'}], candidates=candidates) + qs = filters.run([{"type": "noop"}], candidates=candidates) assert qs.count() == 1 assert qs.first() == tracks[0] def test_filter_on_tag(factories): - tracks = factories['music.Track'].create_batch(3, tags=['metal']) - factories['music.Track'].create_batch(3, tags=['pop']) + tracks = factories["music.Track"].create_batch(3, tags=["metal"]) + factories["music.Track"].create_batch(3, tags=["pop"]) expected = tracks - f = [ - {'type': 'tag', 'names': ['metal']} - ] + f = [{"type": "tag", "names": ["metal"]}] candidates = filters.run(f) - assert list(candidates.order_by('pk')) == expected + assert list(candidates.order_by("pk")) == expected def test_filter_on_artist(factories): - artist1 = factories['music.Artist']() - artist2 = factories['music.Artist']() - factories['music.Track'].create_batch(3, artist=artist1) - factories['music.Track'].create_batch(3, artist=artist2) - expected = list(artist1.tracks.order_by('pk')) - f = [ - {'type': 'artist', 'ids': [artist1.pk]} - ] + artist1 = factories["music.Artist"]() + artist2 = factories["music.Artist"]() + factories["music.Track"].create_batch(3, artist=artist1) + factories["music.Track"].create_batch(3, artist=artist2) + expected = list(artist1.tracks.order_by("pk")) + f = [{"type": "artist", "ids": [artist1.pk]}] candidates = filters.run(f) - assert list(candidates.order_by('pk')) == expected + assert list(candidates.order_by("pk")) == expected def test_can_combine_with_or(factories): - artist1 = factories['music.Artist']() - artist2 = factories['music.Artist']() - artist3 = factories['music.Artist']() - factories['music.Track'].create_batch(3, artist=artist1) - factories['music.Track'].create_batch(3, artist=artist2) - factories['music.Track'].create_batch(3, artist=artist3) - expected = Track.objects.exclude(artist=artist3).order_by('pk') + artist1 = factories["music.Artist"]() + artist2 = factories["music.Artist"]() + artist3 = factories["music.Artist"]() + factories["music.Track"].create_batch(3, artist=artist1) + factories["music.Track"].create_batch(3, artist=artist2) + factories["music.Track"].create_batch(3, artist=artist3) + expected = Track.objects.exclude(artist=artist3).order_by("pk") f = [ - {'type': 'artist', 'ids': [artist1.pk]}, - {'type': 'artist', 'ids': [artist2.pk], 'operator': 'or'}, + {"type": "artist", "ids": [artist1.pk]}, + {"type": "artist", "ids": [artist2.pk], "operator": "or"}, ] candidates = filters.run(f) - assert list(candidates.order_by('pk')) == list(expected) + assert list(candidates.order_by("pk")) == list(expected) def test_can_combine_with_and(factories): - artist1 = factories['music.Artist']() - artist2 = factories['music.Artist']() - metal_tracks = factories['music.Track'].create_batch( - 2, artist=artist1, tags=['metal']) - factories['music.Track'].create_batch(2, artist=artist1, tags=['pop']) - factories['music.Track'].create_batch(3, artist=artist2) + artist1 = factories["music.Artist"]() + artist2 = factories["music.Artist"]() + metal_tracks = factories["music.Track"].create_batch( + 2, artist=artist1, tags=["metal"] + ) + factories["music.Track"].create_batch(2, artist=artist1, tags=["pop"]) + factories["music.Track"].create_batch(3, artist=artist2) expected = metal_tracks f = [ - {'type': 'artist', 'ids': [artist1.pk]}, - {'type': 'tag', 'names': ['metal'], 'operator': 'and'}, + {"type": "artist", "ids": [artist1.pk]}, + {"type": "tag", "names": ["metal"], "operator": "and"}, ] candidates = filters.run(f) - assert list(candidates.order_by('pk')) == list(expected) + assert list(candidates.order_by("pk")) == list(expected) def test_can_negate(factories): - artist1 = factories['music.Artist']() - artist2 = factories['music.Artist']() - factories['music.Track'].create_batch(3, artist=artist1) - factories['music.Track'].create_batch(3, artist=artist2) - expected = artist2.tracks.order_by('pk') - f = [ - {'type': 'artist', 'ids': [artist1.pk], 'not': True}, - ] + artist1 = factories["music.Artist"]() + artist2 = factories["music.Artist"]() + factories["music.Track"].create_batch(3, artist=artist1) + factories["music.Track"].create_batch(3, artist=artist2) + expected = artist2.tracks.order_by("pk") + f = [{"type": "artist", "ids": [artist1.pk], "not": True}] candidates = filters.run(f) - assert list(candidates.order_by('pk')) == list(expected) + assert list(candidates.order_by("pk")) == list(expected) def test_can_group(factories): - artist1 = factories['music.Artist']() - artist2 = factories['music.Artist']() - factories['music.Track'].create_batch(2, artist=artist1) - t1 = factories['music.Track'].create_batch( - 2, artist=artist1, tags=['metal']) - factories['music.Track'].create_batch(2, artist=artist2) - t2 = factories['music.Track'].create_batch( - 2, artist=artist2, tags=['metal']) - factories['music.Track'].create_batch(2, tags=['metal']) + artist1 = factories["music.Artist"]() + artist2 = factories["music.Artist"]() + factories["music.Track"].create_batch(2, artist=artist1) + t1 = factories["music.Track"].create_batch(2, artist=artist1, tags=["metal"]) + factories["music.Track"].create_batch(2, artist=artist2) + t2 = factories["music.Track"].create_batch(2, artist=artist2, tags=["metal"]) + factories["music.Track"].create_batch(2, tags=["metal"]) expected = t1 + t2 f = [ - {'type': 'tag', 'names': ['metal']}, - {'type': 'group', 'operator': 'and', 'filters': [ - {'type': 'artist', 'ids': [artist1.pk], 'operator': 'or'}, - {'type': 'artist', 'ids': [artist2.pk], 'operator': 'or'}, - ]} + {"type": "tag", "names": ["metal"]}, + { + "type": "group", + "operator": "and", + "filters": [ + {"type": "artist", "ids": [artist1.pk], "operator": "or"}, + {"type": "artist", "ids": [artist2.pk], "operator": "or"}, + ], + }, ] candidates = filters.run(f) - assert list(candidates.order_by('pk')) == list(expected) + assert list(candidates.order_by("pk")) == list(expected) def test_artist_filter_clean_config(factories): - artist1 = factories['music.Artist']() - artist2 = factories['music.Artist']() + artist1 = factories["music.Artist"]() + artist2 = factories["music.Artist"]() - config = filters.clean_config( - {'type': 'artist', 'ids': [artist2.pk, artist1.pk]}) + config = filters.clean_config({"type": "artist", "ids": [artist2.pk, artist1.pk]}) expected = { - 'type': 'artist', - 'ids': [artist1.pk, artist2.pk], - 'names': [artist1.name, artist2.name] + "type": "artist", + "ids": [artist1.pk, artist2.pk], + "names": [artist1.name, artist2.name], } assert filters.clean_config(config) == expected def test_can_check_artist_filter(factories): - artist = factories['music.Artist']() + artist = factories["music.Artist"]() - assert filters.validate({'type': 'artist', 'ids': [artist.pk]}) + assert filters.validate({"type": "artist", "ids": [artist.pk]}) with pytest.raises(ValidationError): - filters.validate({'type': 'artist', 'ids': [artist.pk + 1]}) + filters.validate({"type": "artist", "ids": [artist.pk + 1]}) def test_can_check_operator(): - assert filters.validate( - {'type': 'group', 'operator': 'or', 'filters': []}) - assert filters.validate( - {'type': 'group', 'operator': 'and', 'filters': []}) + assert filters.validate({"type": "group", "operator": "or", "filters": []}) + assert filters.validate({"type": "group", "operator": "and", "filters": []}) with pytest.raises(ValidationError): - assert filters.validate( - {'type': 'group', 'operator': 'nope', 'filters': []}) + assert filters.validate({"type": "group", "operator": "nope", "filters": []}) diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py index b166b648c..e218ced90 100644 --- a/api/tests/radios/test_radios.py +++ b/api/tests/radios/test_radios.py @@ -1,15 +1,12 @@ import json import random + import pytest - -from django.urls import reverse from django.core.exceptions import ValidationError +from django.urls import reverse - -from funkwhale_api.radios import radios -from funkwhale_api.radios import models -from funkwhale_api.radios import serializers from funkwhale_api.favorites.models import TrackFavorite +from funkwhale_api.radios import models, radios, serializers def test_can_pick_track_from_choices(): @@ -51,9 +48,9 @@ def test_can_pick_by_weight(): def test_can_get_choices_for_favorites_radio(factories): - files = factories['music.TrackFile'].create_batch(10) + files = factories["music.TrackFile"].create_batch(10) tracks = [f.track for f in files] - user = factories['users.User']() + user = factories["users.User"]() for i in range(5): TrackFavorite.add(track=random.choice(tracks), user=user) @@ -71,71 +68,65 @@ def test_can_get_choices_for_favorites_radio(factories): def test_can_get_choices_for_custom_radio(factories): - artist = factories['music.Artist']() - files = factories['music.TrackFile'].create_batch( - 5, track__artist=artist) + artist = factories["music.Artist"]() + files = factories["music.TrackFile"].create_batch(5, track__artist=artist) tracks = [f.track for f in files] - wrong_files = factories['music.TrackFile'].create_batch(5) - wrong_tracks = [f.track for f in wrong_files] + factories["music.TrackFile"].create_batch(5) - session = factories['radios.CustomRadioSession']( - custom_radio__config=[{'type': 'artist', 'ids': [artist.pk]}] + session = factories["radios.CustomRadioSession"]( + custom_radio__config=[{"type": "artist", "ids": [artist.pk]}] ) choices = session.radio.get_choices() expected = [t.pk for t in tracks] - assert list(choices.values_list('id', flat=True)) == expected + assert list(choices.values_list("id", flat=True)) == expected def test_cannot_start_custom_radio_if_not_owner_or_not_public(factories): - user = factories['users.User']() - artist = factories['music.Artist']() - radio = factories['radios.Radio']( - config=[{'type': 'artist', 'ids': [artist.pk]}] - ) + user = factories["users.User"]() + artist = factories["music.Artist"]() + radio = factories["radios.Radio"](config=[{"type": "artist", "ids": [artist.pk]}]) serializer = serializers.RadioSessionSerializer( - data={ - 'radio_type': 'custom', 'custom_radio': radio.pk, 'user': user.pk} + data={"radio_type": "custom", "custom_radio": radio.pk, "user": user.pk} ) message = "You don't have access to this radio" assert not serializer.is_valid() - assert message in serializer.errors['non_field_errors'] + assert message in serializer.errors["non_field_errors"] def test_can_start_custom_radio_from_api(logged_in_client, factories): - artist = factories['music.Artist']() - radio = factories['radios.Radio']( - config=[{'type': 'artist', 'ids': [artist.pk]}], - user=logged_in_client.user + artist = factories["music.Artist"]() + radio = factories["radios.Radio"]( + config=[{"type": "artist", "ids": [artist.pk]}], user=logged_in_client.user ) - url = reverse('api:v1:radios:sessions-list') + url = reverse("api:v1:radios:sessions-list") response = logged_in_client.post( - url, {'radio_type': 'custom', 'custom_radio': radio.pk}) + url, {"radio_type": "custom", "custom_radio": radio.pk} + ) assert response.status_code == 201 - session = radio.sessions.latest('id') - assert session.radio_type == 'custom' + session = radio.sessions.latest("id") + assert session.radio_type == "custom" assert session.user == logged_in_client.user def test_can_use_radio_session_to_filter_choices(factories): - files = factories['music.TrackFile'].create_batch(30) - tracks = [f.track for f in files] - user = factories['users.User']() + factories["music.TrackFile"].create_batch(30) + user = factories["users.User"]() radio = radios.RandomRadio() session = radio.start_session(user) for i in range(30): - p = radio.pick() + radio.pick() # ensure 30 differents tracks have been suggested tracks_id = [ - session_track.track.pk - for session_track in session.session_tracks.all()] + session_track.track.pk for session_track in session.session_tracks.all() + ] assert len(set(tracks_id)) == 30 def test_can_restore_radio_from_previous_session(factories): - user = factories['users.User']() + user = factories["users.User"]() radio = radios.RandomRadio() session = radio.start_session(user) @@ -144,37 +135,37 @@ def test_can_restore_radio_from_previous_session(factories): def test_can_start_radio_for_logged_in_user(logged_in_client): - url = reverse('api:v1:radios:sessions-list') - response = logged_in_client.post(url, {'radio_type': 'random'}) - session = models.RadioSession.objects.latest('id') - assert session.radio_type == 'random' + url = reverse("api:v1:radios:sessions-list") + logged_in_client.post(url, {"radio_type": "random"}) + session = models.RadioSession.objects.latest("id") + assert session.radio_type == "random" assert session.user == logged_in_client.user def test_can_get_track_for_session_from_api(factories, logged_in_client): - files = factories['music.TrackFile'].create_batch(1) + files = factories["music.TrackFile"].create_batch(1) tracks = [f.track for f in files] - url = reverse('api:v1:radios:sessions-list') - response = logged_in_client.post(url, {'radio_type': 'random'}) - session = models.RadioSession.objects.latest('id') + url = reverse("api:v1:radios:sessions-list") + response = logged_in_client.post(url, {"radio_type": "random"}) + session = models.RadioSession.objects.latest("id") - url = reverse('api:v1:radios:tracks-list') - response = logged_in_client.post(url, {'session': session.pk}) - data = json.loads(response.content.decode('utf-8')) + url = reverse("api:v1:radios:tracks-list") + response = logged_in_client.post(url, {"session": session.pk}) + data = json.loads(response.content.decode("utf-8")) - assert data['track']['id'] == tracks[0].id - assert data['position'] == 1 + assert data["track"]["id"] == tracks[0].id + assert data["position"] == 1 - next_track = factories['music.TrackFile']().track - response = logged_in_client.post(url, {'session': session.pk}) - data = json.loads(response.content.decode('utf-8')) + next_track = factories["music.TrackFile"]().track + response = logged_in_client.post(url, {"session": session.pk}) + data = json.loads(response.content.decode("utf-8")) - assert data['track']['id'] == next_track.id - assert data['position'] == 2 + assert data["track"]["id"] == next_track.id + assert data["position"] == 2 def test_related_object_radio_validate_related_object(factories): - user = factories['users.User']() + user = factories["users.User"]() # cannot start without related object radio = radios.ArtistRadio() with pytest.raises(ValidationError): @@ -187,62 +178,58 @@ def test_related_object_radio_validate_related_object(factories): def test_can_start_artist_radio(factories): - user = factories['users.User']() - artist = factories['music.Artist']() - wrong_files = factories['music.TrackFile'].create_batch(5) - wrong_tracks = [f.track for f in wrong_files] - good_files = factories['music.TrackFile'].create_batch( - 5, track__artist=artist) + user = factories["users.User"]() + artist = factories["music.Artist"]() + factories["music.TrackFile"].create_batch(5) + good_files = factories["music.TrackFile"].create_batch(5, track__artist=artist) good_tracks = [f.track for f in good_files] radio = radios.ArtistRadio() session = radio.start_session(user, related_object=artist) - assert session.radio_type == 'artist' + assert session.radio_type == "artist" for i in range(5): assert radio.pick() in good_tracks def test_can_start_tag_radio(factories): - user = factories['users.User']() - tag = factories['taggit.Tag']() - wrong_files = factories['music.TrackFile'].create_batch(5) - wrong_tracks = [f.track for f in wrong_files] - good_files = factories['music.TrackFile'].create_batch( - 5, track__tags=[tag]) + user = factories["users.User"]() + tag = factories["taggit.Tag"]() + factories["music.TrackFile"].create_batch(5) + good_files = factories["music.TrackFile"].create_batch(5, track__tags=[tag]) good_tracks = [f.track for f in good_files] radio = radios.TagRadio() session = radio.start_session(user, related_object=tag) - assert session.radio_type == 'tag' + assert session.radio_type == "tag" for i in range(5): assert radio.pick() in good_tracks -def test_can_start_artist_radio_from_api( - logged_in_api_client, preferences, factories): - artist = factories['music.Artist']() - url = reverse('api:v1:radios:sessions-list') +def test_can_start_artist_radio_from_api(logged_in_api_client, preferences, factories): + artist = factories["music.Artist"]() + url = reverse("api:v1:radios:sessions-list") response = logged_in_api_client.post( - url, {'radio_type': 'artist', 'related_object_id': artist.id}) + url, {"radio_type": "artist", "related_object_id": artist.id} + ) assert response.status_code == 201 - session = models.RadioSession.objects.latest('id') + session = models.RadioSession.objects.latest("id") - assert session.radio_type == 'artist' + assert session.radio_type == "artist" assert session.related_object == artist def test_can_start_less_listened_radio(factories): - user = factories['users.User']() - wrong_files = factories['music.TrackFile'].create_batch(5) + user = factories["users.User"]() + wrong_files = factories["music.TrackFile"].create_batch(5) for f in wrong_files: - factories['history.Listening'](track=f.track, user=user) - good_files = factories['music.TrackFile'].create_batch(5) + factories["history.Listening"](track=f.track, user=user) + good_files = factories["music.TrackFile"].create_batch(5) good_tracks = [f.track for f in good_files] radio = radios.LessListenedRadio() - session = radio.start_session(user) + radio.start_session(user) for i in range(5): assert radio.pick() in good_tracks diff --git a/api/tests/requests/test_models.py b/api/tests/requests/test_models.py index 797656bd7..3ac8a5342 100644 --- a/api/tests/requests/test_models.py +++ b/api/tests/requests/test_models.py @@ -1,23 +1,18 @@ -import pytest - -from django.forms import ValidationError - - def test_can_bind_import_batch_to_request(factories): - request = factories['requests.ImportRequest']() + request = factories["requests.ImportRequest"]() - assert request.status == 'pending' + assert request.status == "pending" # when we create the import, we consider the request as accepted - batch = factories['music.ImportBatch'](import_request=request) + batch = factories["music.ImportBatch"](import_request=request) request.refresh_from_db() - assert request.status == 'accepted' + assert request.status == "accepted" # now, the batch is finished, therefore the request status should be # imported - batch.status = 'finished' - batch.save(update_fields=['status']) + batch.status = "finished" + batch.save(update_fields=["status"]) request.refresh_from_db() - assert request.status == 'imported' + assert request.status == "imported" diff --git a/api/tests/requests/test_views.py b/api/tests/requests/test_views.py index 6c34f9ad1..0d6433672 100644 --- a/api/tests/requests/test_views.py +++ b/api/tests/requests/test_views.py @@ -2,25 +2,25 @@ from django.urls import reverse def test_request_viewset_requires_auth(db, api_client): - url = reverse('api:v1:requests:import-requests-list') + url = reverse("api:v1:requests:import-requests-list") response = api_client.get(url) assert response.status_code == 401 def test_user_can_create_request(logged_in_api_client): - url = reverse('api:v1:requests:import-requests-list') + url = reverse("api:v1:requests:import-requests-list") user = logged_in_api_client.user data = { - 'artist_name': 'System of a Down', - 'albums': 'All please!', - 'comment': 'Please, they rock!', + "artist_name": "System of a Down", + "albums": "All please!", + "comment": "Please, they rock!", } response = logged_in_api_client.post(url, data) assert response.status_code == 201 - ir = user.import_requests.latest('id') - assert ir.status == 'pending' + ir = user.import_requests.latest("id") + assert ir.status == "pending" assert ir.creation_date is not None for field, value in data.items(): assert getattr(ir, field) == value diff --git a/api/tests/subsonic/test_authentication.py b/api/tests/subsonic/test_authentication.py index 656f8c44d..b2d2c0400 100644 --- a/api/tests/subsonic/test_authentication.py +++ b/api/tests/subsonic/test_authentication.py @@ -1,22 +1,18 @@ import binascii -import pytest +import pytest from rest_framework import exceptions from funkwhale_api.subsonic import authentication def test_auth_with_salt(api_request, factories): - salt = 'salt' - user = factories['users.User']() - user.subsonic_api_token = 'password' + salt = "salt" + user = factories["users.User"]() + user.subsonic_api_token = "password" user.save() - token = authentication.get_token(salt, 'password') - request = api_request.get('/', { - 't': token, - 's': salt, - 'u': user.username - }) + token = authentication.get_token(salt, "password") + request = api_request.get("/", {"t": token, "s": salt, "u": user.username}) authenticator = authentication.SubsonicAuthentication() u, _ = authenticator.authenticate(request) @@ -25,16 +21,20 @@ def test_auth_with_salt(api_request, factories): def test_auth_with_password_hex(api_request, factories): - salt = 'salt' - user = factories['users.User']() - user.subsonic_api_token = 'password' + user = factories["users.User"]() + user.subsonic_api_token = "password" user.save() - token = authentication.get_token(salt, 'password') - request = api_request.get('/', { - 'u': user.username, - 'p': 'enc:{}'.format(binascii.hexlify( - user.subsonic_api_token.encode('utf-8')).decode('utf-8')) - }) + request = api_request.get( + "/", + { + "u": user.username, + "p": "enc:{}".format( + binascii.hexlify(user.subsonic_api_token.encode("utf-8")).decode( + "utf-8" + ) + ), + }, + ) authenticator = authentication.SubsonicAuthentication() u, _ = authenticator.authenticate(request) @@ -43,15 +43,10 @@ def test_auth_with_password_hex(api_request, factories): def test_auth_with_password_cleartext(api_request, factories): - salt = 'salt' - user = factories['users.User']() - user.subsonic_api_token = 'password' + user = factories["users.User"]() + user.subsonic_api_token = "password" user.save() - token = authentication.get_token(salt, 'password') - request = api_request.get('/', { - 'u': user.username, - 'p': 'password', - }) + request = api_request.get("/", {"u": user.username, "p": "password"}) authenticator = authentication.SubsonicAuthentication() u, _ = authenticator.authenticate(request) @@ -60,15 +55,10 @@ def test_auth_with_password_cleartext(api_request, factories): def test_auth_with_inactive_users(api_request, factories): - salt = 'salt' - user = factories['users.User'](is_active=False) - user.subsonic_api_token = 'password' + user = factories["users.User"](is_active=False) + user.subsonic_api_token = "password" user.save() - token = authentication.get_token(salt, 'password') - request = api_request.get('/', { - 'u': user.username, - 'p': 'password', - }) + request = api_request.get("/", {"u": user.username, "p": "password"}) authenticator = authentication.SubsonicAuthentication() with pytest.raises(exceptions.AuthenticationFailed): diff --git a/api/tests/subsonic/test_renderers.py b/api/tests/subsonic/test_renderers.py index 8e2ea3f85..301fee8b5 100644 --- a/api/tests/subsonic/test_renderers.py +++ b/api/tests/subsonic/test_renderers.py @@ -5,38 +5,26 @@ from funkwhale_api.subsonic import renderers def test_json_renderer(): - data = {'hello': 'world'} + data = {"hello": "world"} expected = { - 'subsonic-response': { - 'status': 'ok', - 'version': '1.16.0', - 'hello': 'world' - } + "subsonic-response": {"status": "ok", "version": "1.16.0", "hello": "world"} } renderer = renderers.SubsonicJSONRenderer() assert json.loads(renderer.render(data)) == expected def test_xml_renderer_dict_to_xml(): - payload = { - 'hello': 'world', - 'item': [ - {'this': 1}, - {'some': 'node'}, - ] - } + payload = {"hello": "world", "item": [{"this": 1}, {"some": "node"}]} expected = """ """ - result = renderers.dict_to_xml_tree('key', payload) + result = renderers.dict_to_xml_tree("key", payload) exp = ET.fromstring(expected) assert ET.tostring(result) == ET.tostring(exp) def test_xml_renderer(): - payload = { - 'hello': 'world', - } - expected = b'\n' + payload = {"hello": "world"} + expected = b'\n' # noqa renderer = renderers.SubsonicXMLRenderer() rendered = renderer.render(payload) diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index 6b9ec232d..6fdf02e2d 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -3,136 +3,121 @@ from funkwhale_api.subsonic import serializers def test_get_artists_serializer(factories): - artist1 = factories['music.Artist'](name='eliot') - artist2 = factories['music.Artist'](name='Ellena') - artist3 = factories['music.Artist'](name='Rilay') + artist1 = factories["music.Artist"](name="eliot") + artist2 = factories["music.Artist"](name="Ellena") + artist3 = factories["music.Artist"](name="Rilay") - factories['music.Album'].create_batch(size=3, artist=artist1) - factories['music.Album'].create_batch(size=2, artist=artist2) + factories["music.Album"].create_batch(size=3, artist=artist1) + factories["music.Album"].create_batch(size=2, artist=artist2) expected = { - 'ignoredArticles': '', - 'index': [ + "ignoredArticles": "", + "index": [ { - 'name': 'E', - 'artist': [ - { - 'id': artist1.pk, - 'name': artist1.name, - 'albumCount': 3, - }, - { - 'id': artist2.pk, - 'name': artist2.name, - 'albumCount': 2, - }, - ] + "name": "E", + "artist": [ + {"id": artist1.pk, "name": artist1.name, "albumCount": 3}, + {"id": artist2.pk, "name": artist2.name, "albumCount": 2}, + ], }, { - 'name': 'R', - 'artist': [ - { - 'id': artist3.pk, - 'name': artist3.name, - 'albumCount': 0, - }, - ] + "name": "R", + "artist": [{"id": artist3.pk, "name": artist3.name, "albumCount": 0}], }, - ] + ], } - queryset = artist1.__class__.objects.filter(pk__in=[ - artist1.pk, artist2.pk, artist3.pk - ]) + queryset = artist1.__class__.objects.filter( + pk__in=[artist1.pk, artist2.pk, artist3.pk] + ) assert serializers.GetArtistsSerializer(queryset).data == expected def test_get_artist_serializer(factories): - artist = factories['music.Artist']() - album = factories['music.Album'](artist=artist) - tracks = factories['music.Track'].create_batch(size=3, album=album) + artist = factories["music.Artist"]() + album = factories["music.Album"](artist=artist) + tracks = factories["music.Track"].create_batch(size=3, album=album) expected = { - 'id': artist.pk, - 'name': artist.name, - 'albumCount': 1, - 'album': [ + "id": artist.pk, + "name": artist.name, + "albumCount": 1, + "album": [ { - 'id': album.pk, - 'coverArt': 'al-{}'.format(album.id), - 'artistId': artist.pk, - 'name': album.title, - 'artist': artist.name, - 'songCount': len(tracks), - 'created': album.creation_date, - 'year': album.release_date.year, + "id": album.pk, + "coverArt": "al-{}".format(album.id), + "artistId": artist.pk, + "name": album.title, + "artist": artist.name, + "songCount": len(tracks), + "created": album.creation_date, + "year": album.release_date.year, } - ] + ], } assert serializers.GetArtistSerializer(artist).data == expected def test_get_album_serializer(factories): - artist = factories['music.Artist']() - album = factories['music.Album'](artist=artist) - track = factories['music.Track'](album=album) - tf = factories['music.TrackFile']( - track=track, bitrate=42000, duration=43, size=44) + artist = factories["music.Artist"]() + album = factories["music.Album"](artist=artist) + track = factories["music.Track"](album=album) + tf = factories["music.TrackFile"](track=track, bitrate=42000, duration=43, size=44) expected = { - 'id': album.pk, - 'artistId': artist.pk, - 'name': album.title, - 'artist': artist.name, - 'songCount': 1, - 'created': album.creation_date, - 'year': album.release_date.year, - 'coverArt': 'al-{}'.format(album.id), - 'song': [ + "id": album.pk, + "artistId": artist.pk, + "name": album.title, + "artist": artist.name, + "songCount": 1, + "created": album.creation_date, + "year": album.release_date.year, + "coverArt": "al-{}".format(album.id), + "song": [ { - 'id': track.pk, - 'isDir': 'false', - 'title': track.title, - 'coverArt': 'al-{}'.format(album.id), - 'album': album.title, - 'artist': artist.name, - 'track': track.position, - 'year': track.album.release_date.year, - 'contentType': tf.mimetype, - 'suffix': tf.extension or '', - 'bitrate': 42, - 'duration': 43, - 'size': 44, - 'created': track.creation_date, - 'albumId': album.pk, - 'artistId': artist.pk, - 'type': 'music', + "id": track.pk, + "isDir": "false", + "title": track.title, + "coverArt": "al-{}".format(album.id), + "album": album.title, + "artist": artist.name, + "track": track.position, + "year": track.album.release_date.year, + "contentType": tf.mimetype, + "suffix": tf.extension or "", + "bitrate": 42, + "duration": 43, + "size": 44, + "created": track.creation_date, + "albumId": album.pk, + "artistId": artist.pk, + "type": "music", } - ] + ], } assert serializers.GetAlbumSerializer(album).data == expected def test_starred_tracks2_serializer(factories): - artist = factories['music.Artist']() - album = factories['music.Album'](artist=artist) - track = factories['music.Track'](album=album) - tf = factories['music.TrackFile'](track=track) - favorite = factories['favorites.TrackFavorite'](track=track) + artist = factories["music.Artist"]() + album = factories["music.Album"](artist=artist) + track = factories["music.Track"](album=album) + tf = factories["music.TrackFile"](track=track) + favorite = factories["favorites.TrackFavorite"](track=track) expected = [serializers.get_track_data(album, track, tf)] - expected[0]['starred'] = favorite.creation_date + expected[0]["starred"] = favorite.creation_date data = serializers.get_starred_tracks_data([favorite]) assert data == expected def test_get_album_list2_serializer(factories): - album1 = factories['music.Album']() - album2 = factories['music.Album']() + album1 = factories["music.Album"]() + album2 = factories["music.Album"]() - qs = music_models.Album.objects.with_tracks_count().order_by('pk') + qs = music_models.Album.objects.with_tracks_count().order_by("pk") expected = [ serializers.get_album2_data(album1), serializers.get_album2_data(album2), @@ -142,17 +127,17 @@ def test_get_album_list2_serializer(factories): def test_playlist_serializer(factories): - plt = factories['playlists.PlaylistTrack']() + plt = factories["playlists.PlaylistTrack"]() playlist = plt.playlist - qs = music_models.Album.objects.with_tracks_count().order_by('pk') + qs = music_models.Album.objects.with_tracks_count().order_by("pk") expected = { - 'id': playlist.pk, - 'name': playlist.name, - 'owner': playlist.user.username, - 'public': 'false', - 'songCount': 1, - 'duration': 0, - 'created': playlist.creation_date, + "id": playlist.pk, + "name": playlist.name, + "owner": playlist.user.username, + "public": "false", + "songCount": 1, + "duration": 0, + "created": playlist.creation_date, } qs = playlist.__class__.objects.with_tracks_count() data = serializers.get_playlist_data(qs.first()) @@ -160,21 +145,19 @@ def test_playlist_serializer(factories): def test_playlist_detail_serializer(factories): - plt = factories['playlists.PlaylistTrack']() - tf = factories['music.TrackFile'](track=plt.track) + plt = factories["playlists.PlaylistTrack"]() + tf = factories["music.TrackFile"](track=plt.track) playlist = plt.playlist - qs = music_models.Album.objects.with_tracks_count().order_by('pk') + qs = music_models.Album.objects.with_tracks_count().order_by("pk") expected = { - 'id': playlist.pk, - 'name': playlist.name, - 'owner': playlist.user.username, - 'public': 'false', - 'songCount': 1, - 'duration': 0, - 'created': playlist.creation_date, - 'entry': [ - serializers.get_track_data(plt.track.album, plt.track, tf) - ] + "id": playlist.pk, + "name": playlist.name, + "owner": playlist.user.username, + "public": "false", + "songCount": 1, + "duration": 0, + "created": playlist.creation_date, + "entry": [serializers.get_track_data(plt.track.album, plt.track, tf)], } qs = playlist.__class__.objects.with_tracks_count() data = serializers.get_playlist_detail_data(qs.first()) @@ -182,50 +165,47 @@ def test_playlist_detail_serializer(factories): def test_directory_serializer_artist(factories): - track = factories['music.Track']() - tf = factories['music.TrackFile']( - track=track, bitrate=42000, duration=43, size=44) + track = factories["music.Track"]() + tf = factories["music.TrackFile"](track=track, bitrate=42000, duration=43, size=44) album = track.album artist = track.artist expected = { - 'id': artist.pk, - 'parent': 1, - 'name': artist.name, - 'child': [{ - 'id': track.pk, - 'isDir': 'false', - 'title': track.title, - 'album': album.title, - 'artist': artist.name, - 'track': track.position, - 'year': track.album.release_date.year, - 'contentType': tf.mimetype, - 'suffix': tf.extension or '', - 'bitrate': 42, - 'duration': 43, - 'size': 44, - 'created': track.creation_date, - 'albumId': album.pk, - 'artistId': artist.pk, - 'parent': artist.pk, - 'type': 'music', - }] + "id": artist.pk, + "parent": 1, + "name": artist.name, + "child": [ + { + "id": track.pk, + "isDir": "false", + "title": track.title, + "album": album.title, + "artist": artist.name, + "track": track.position, + "year": track.album.release_date.year, + "contentType": tf.mimetype, + "suffix": tf.extension or "", + "bitrate": 42, + "duration": 43, + "size": 44, + "created": track.creation_date, + "albumId": album.pk, + "artistId": artist.pk, + "parent": artist.pk, + "type": "music", + } + ], } data = serializers.get_music_directory_data(artist) assert data == expected def test_scrobble_serializer(factories): - tf = factories['music.TrackFile']() + tf = factories["music.TrackFile"]() track = tf.track - user = factories['users.User']() - payload = { - 'id': track.pk, - 'submission': True, - } - serializer = serializers.ScrobbleSerializer( - data=payload, context={'user': user}) + user = factories["users.User"]() + payload = {"id": track.pk, "submission": True} + serializer = serializers.ScrobbleSerializer(data=payload, context={"user": user}) assert serializer.is_valid(raise_exception=True) diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index 52e410e52..b7431efab 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -1,16 +1,13 @@ import datetime import json + import pytest - -from django.utils import timezone from django.urls import reverse - -from rest_framework.response import Response +from django.utils import timezone from funkwhale_api.music import models as music_models from funkwhale_api.music import views as music_views -from funkwhale_api.subsonic import renderers -from funkwhale_api.subsonic import serializers +from funkwhale_api.subsonic import renderers, serializers def render_json(data): @@ -18,372 +15,355 @@ def render_json(data): def test_render_content_json(db, api_client): - url = reverse('api:subsonic-ping') - response = api_client.get(url, {'f': 'json'}) + url = reverse("api:subsonic-ping") + response = api_client.get(url, {"f": "json"}) - expected = { - 'status': 'ok', - 'version': '1.16.0' - } + expected = {"status": "ok", "version": "1.16.0"} assert response.status_code == 200 assert json.loads(response.content) == render_json(expected) -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_exception_wrong_credentials(f, db, api_client): - url = reverse('api:subsonic-ping') - response = api_client.get(url, {'f': f, 'u': 'yolo'}) + url = reverse("api:subsonic-ping") + response = api_client.get(url, {"f": f, "u": "yolo"}) expected = { - 'status': 'failed', - 'error': { - 'code': 40, - 'message': 'Wrong username or password.' - } + "status": "failed", + "error": {"code": 40, "message": "Wrong username or password."}, } assert response.status_code == 200 assert response.data == expected def test_disabled_subsonic(preferences, api_client): - preferences['subsonic__enabled'] = False - url = reverse('api:subsonic-ping') + preferences["subsonic__enabled"] = False + url = reverse("api:subsonic-ping") response = api_client.get(url) assert response.status_code == 405 -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_license(f, db, logged_in_api_client, mocker): - url = reverse('api:subsonic-get-license') - assert url.endswith('getLicense') is True + url = reverse("api:subsonic-get-license") + assert url.endswith("getLicense") is True now = timezone.now() - mocker.patch('django.utils.timezone.now', return_value=now) - response = logged_in_api_client.get(url, {'f': f}) + mocker.patch("django.utils.timezone.now", return_value=now) + response = logged_in_api_client.get(url, {"f": f}) expected = { - 'status': 'ok', - 'version': '1.16.0', - 'license': { - 'valid': 'true', - 'email': 'valid@valid.license', - 'licenseExpires': now + datetime.timedelta(days=365) - } + "status": "ok", + "version": "1.16.0", + "license": { + "valid": "true", + "email": "valid@valid.license", + "licenseExpires": now + datetime.timedelta(days=365), + }, } assert response.status_code == 200 assert response.data == expected -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_ping(f, db, api_client): - url = reverse('api:subsonic-ping') - response = api_client.get(url, {'f': f}) + url = reverse("api:subsonic-ping") + response = api_client.get(url, {"f": f}) - expected = { - 'status': 'ok', - 'version': '1.16.0', - } + expected = {"status": "ok", "version": "1.16.0"} assert response.status_code == 200 assert response.data == expected -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_artists(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-artists') - assert url.endswith('getArtists') is True - artists = factories['music.Artist'].create_batch(size=10) + url = reverse("api:subsonic-get-artists") + assert url.endswith("getArtists") is True + factories["music.Artist"].create_batch(size=10) expected = { - 'artists': serializers.GetArtistsSerializer( + "artists": serializers.GetArtistsSerializer( music_models.Artist.objects.all() ).data } - response = logged_in_api_client.get(url, {'f': f}) + response = logged_in_api_client.get(url, {"f": f}) assert response.status_code == 200 assert response.data == expected -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_artist(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-artist') - assert url.endswith('getArtist') is True - artist = factories['music.Artist']() - albums = factories['music.Album'].create_batch(size=3, artist=artist) - expected = { - 'artist': serializers.GetArtistSerializer(artist).data - } - response = logged_in_api_client.get(url, {'id': artist.pk}) + url = reverse("api:subsonic-get-artist") + assert url.endswith("getArtist") is True + artist = factories["music.Artist"]() + factories["music.Album"].create_batch(size=3, artist=artist) + expected = {"artist": serializers.GetArtistSerializer(artist).data} + response = logged_in_api_client.get(url, {"id": artist.pk}) assert response.status_code == 200 assert response.data == expected -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_artist_info2(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-artist-info2') - assert url.endswith('getArtistInfo2') is True - artist = factories['music.Artist']() + url = reverse("api:subsonic-get-artist-info2") + assert url.endswith("getArtistInfo2") is True + artist = factories["music.Artist"]() - expected = { - 'artist-info2': {} - } - response = logged_in_api_client.get(url, {'id': artist.pk}) + expected = {"artist-info2": {}} + response = logged_in_api_client.get(url, {"id": artist.pk}) assert response.status_code == 200 assert response.data == expected -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_album(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-album') - assert url.endswith('getAlbum') is True - artist = factories['music.Artist']() - album = factories['music.Album'](artist=artist) - tracks = factories['music.Track'].create_batch(size=3, album=album) - expected = { - 'album': serializers.GetAlbumSerializer(album).data - } - response = logged_in_api_client.get(url, {'f': f, 'id': album.pk}) + url = reverse("api:subsonic-get-album") + assert url.endswith("getAlbum") is True + artist = factories["music.Artist"]() + album = factories["music.Album"](artist=artist) + factories["music.Track"].create_batch(size=3, album=album) + expected = {"album": serializers.GetAlbumSerializer(album).data} + response = logged_in_api_client.get(url, {"f": f, "id": album.pk}) assert response.status_code == 200 assert response.data == expected -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_stream(f, db, logged_in_api_client, factories, mocker): - url = reverse('api:subsonic-stream') - mocked_serve = mocker.spy( - music_views, 'handle_serve') - assert url.endswith('stream') is True - artist = factories['music.Artist']() - album = factories['music.Album'](artist=artist) - track = factories['music.Track'](album=album) - tf = factories['music.TrackFile'](track=track) - response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + url = reverse("api:subsonic-stream") + mocked_serve = mocker.spy(music_views, "handle_serve") + assert url.endswith("stream") is True + artist = factories["music.Artist"]() + album = factories["music.Album"](artist=artist) + track = factories["music.Track"](album=album) + tf = factories["music.TrackFile"](track=track) + response = logged_in_api_client.get(url, {"f": f, "id": track.pk}) - mocked_serve.assert_called_once_with( - track_file=tf - ) + mocked_serve.assert_called_once_with(track_file=tf) assert response.status_code == 200 -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_star(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-star') - assert url.endswith('star') is True - track = factories['music.Track']() - response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + url = reverse("api:subsonic-star") + assert url.endswith("star") is True + track = factories["music.Track"]() + response = logged_in_api_client.get(url, {"f": f, "id": track.pk}) assert response.status_code == 200 - assert response.data == {'status': 'ok'} + assert response.data == {"status": "ok"} - favorite = logged_in_api_client.user.track_favorites.latest('id') + favorite = logged_in_api_client.user.track_favorites.latest("id") assert favorite.track == track -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_unstar(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-unstar') - assert url.endswith('unstar') is True - track = factories['music.Track']() - favorite = factories['favorites.TrackFavorite']( - track=track, user=logged_in_api_client.user) - response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + url = reverse("api:subsonic-unstar") + assert url.endswith("unstar") is True + track = factories["music.Track"]() + factories["favorites.TrackFavorite"](track=track, user=logged_in_api_client.user) + response = logged_in_api_client.get(url, {"f": f, "id": track.pk}) assert response.status_code == 200 - assert response.data == {'status': 'ok'} + assert response.data == {"status": "ok"} assert logged_in_api_client.user.track_favorites.count() == 0 -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_starred2(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-starred2') - assert url.endswith('getStarred2') is True - track = factories['music.Track']() - favorite = factories['favorites.TrackFavorite']( - track=track, user=logged_in_api_client.user) - response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + url = reverse("api:subsonic-get-starred2") + assert url.endswith("getStarred2") is True + track = factories["music.Track"]() + favorite = factories["favorites.TrackFavorite"]( + track=track, user=logged_in_api_client.user + ) + response = logged_in_api_client.get(url, {"f": f, "id": track.pk}) assert response.status_code == 200 assert response.data == { - 'starred2': { - 'song': serializers.get_starred_tracks_data([favorite]) - } + "starred2": {"song": serializers.get_starred_tracks_data([favorite])} } -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_starred(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-starred') - assert url.endswith('getStarred') is True - track = factories['music.Track']() - favorite = factories['favorites.TrackFavorite']( - track=track, user=logged_in_api_client.user) - response = logged_in_api_client.get(url, {'f': f, 'id': track.pk}) + url = reverse("api:subsonic-get-starred") + assert url.endswith("getStarred") is True + track = factories["music.Track"]() + favorite = factories["favorites.TrackFavorite"]( + track=track, user=logged_in_api_client.user + ) + response = logged_in_api_client.get(url, {"f": f, "id": track.pk}) assert response.status_code == 200 assert response.data == { - 'starred': { - 'song': serializers.get_starred_tracks_data([favorite]) - } + "starred": {"song": serializers.get_starred_tracks_data([favorite])} } -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_album_list2(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-album-list2') - assert url.endswith('getAlbumList2') is True - album1 = factories['music.Album']() - album2 = factories['music.Album']() - response = logged_in_api_client.get(url, {'f': f, 'type': 'newest'}) + url = reverse("api:subsonic-get-album-list2") + assert url.endswith("getAlbumList2") is True + album1 = factories["music.Album"]() + album2 = factories["music.Album"]() + response = logged_in_api_client.get(url, {"f": f, "type": "newest"}) assert response.status_code == 200 assert response.data == { - 'albumList2': { - 'album': serializers.get_album_list2_data([album2, album1]) - } + "albumList2": {"album": serializers.get_album_list2_data([album2, album1])} } -@pytest.mark.parametrize('f', ['xml', 'json']) -def test_search3(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-search3') - assert url.endswith('search3') is True - artist = factories['music.Artist'](name='testvalue') - factories['music.Artist'](name='nope') - album = factories['music.Album'](title='testvalue') - factories['music.Album'](title='nope') - track = factories['music.Track'](title='testvalue') - factories['music.Track'](title='nope') - - response = logged_in_api_client.get(url, {'f': f, 'query': 'testval'}) - - artist_qs = music_models.Artist.objects.with_albums_count().filter( - pk=artist.pk).values('_albums_count', 'id', 'name') - assert response.status_code == 200 - assert response.data == { - 'searchResult3': { - 'artist': [serializers.get_artist_data(a) for a in artist_qs], - 'album': serializers.get_album_list2_data([album]), - 'song': serializers.get_song_list_data([track]), - } - } - - -@pytest.mark.parametrize('f', ['xml', 'json']) -def test_get_playlists(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-playlists') - assert url.endswith('getPlaylists') is True - playlist = factories['playlists.Playlist']( - user=logged_in_api_client.user - ) - response = logged_in_api_client.get(url, {'f': f}) - - qs = playlist.__class__.objects.with_tracks_count() - assert response.status_code == 200 - assert response.data == { - 'playlists': { - 'playlist': [serializers.get_playlist_data(qs.first())], - } - } - - -@pytest.mark.parametrize('f', ['xml', 'json']) -def test_get_playlist(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-playlist') - assert url.endswith('getPlaylist') is True - playlist = factories['playlists.Playlist']( - user=logged_in_api_client.user - ) - response = logged_in_api_client.get(url, {'f': f, 'id': playlist.pk}) - - qs = playlist.__class__.objects.with_tracks_count() - assert response.status_code == 200 - assert response.data == { - 'playlist': serializers.get_playlist_detail_data(qs.first()) - } - - -@pytest.mark.parametrize('f', ['xml', 'json']) -def test_update_playlist(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-update-playlist') - assert url.endswith('updatePlaylist') is True - playlist = factories['playlists.Playlist']( - user=logged_in_api_client.user - ) - plt = factories['playlists.PlaylistTrack']( - index=0, playlist=playlist) - new_track = factories['music.Track']() +@pytest.mark.parametrize("f", ["xml", "json"]) +def test_get_album_list2_pagination(f, db, logged_in_api_client, factories): + url = reverse("api:subsonic-get-album-list2") + assert url.endswith("getAlbumList2") is True + album1 = factories["music.Album"]() + factories["music.Album"]() response = logged_in_api_client.get( - url, { - 'f': f, - 'name': 'new_name', - 'playlistId': playlist.pk, - 'songIdToAdd': new_track.pk, - 'songIndexToRemove': 0}) + url, {"f": f, "type": "newest", "size": 1, "offset": 1} + ) + + assert response.status_code == 200 + assert response.data == { + "albumList2": {"album": serializers.get_album_list2_data([album1])} + } + + +@pytest.mark.parametrize("f", ["xml", "json"]) +def test_search3(f, db, logged_in_api_client, factories): + url = reverse("api:subsonic-search3") + assert url.endswith("search3") is True + artist = factories["music.Artist"](name="testvalue") + factories["music.Artist"](name="nope") + album = factories["music.Album"](title="testvalue") + factories["music.Album"](title="nope") + track = factories["music.Track"](title="testvalue") + factories["music.Track"](title="nope") + + response = logged_in_api_client.get(url, {"f": f, "query": "testval"}) + + artist_qs = ( + music_models.Artist.objects.with_albums_count() + .filter(pk=artist.pk) + .values("_albums_count", "id", "name") + ) + assert response.status_code == 200 + assert response.data == { + "searchResult3": { + "artist": [serializers.get_artist_data(a) for a in artist_qs], + "album": serializers.get_album_list2_data([album]), + "song": serializers.get_song_list_data([track]), + } + } + + +@pytest.mark.parametrize("f", ["xml", "json"]) +def test_get_playlists(f, db, logged_in_api_client, factories): + url = reverse("api:subsonic-get-playlists") + assert url.endswith("getPlaylists") is True + playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + response = logged_in_api_client.get(url, {"f": f}) + + qs = playlist.__class__.objects.with_tracks_count() + assert response.status_code == 200 + assert response.data == { + "playlists": {"playlist": [serializers.get_playlist_data(qs.first())]} + } + + +@pytest.mark.parametrize("f", ["xml", "json"]) +def test_get_playlist(f, db, logged_in_api_client, factories): + url = reverse("api:subsonic-get-playlist") + assert url.endswith("getPlaylist") is True + playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + response = logged_in_api_client.get(url, {"f": f, "id": playlist.pk}) + + qs = playlist.__class__.objects.with_tracks_count() + assert response.status_code == 200 + assert response.data == { + "playlist": serializers.get_playlist_detail_data(qs.first()) + } + + +@pytest.mark.parametrize("f", ["xml", "json"]) +def test_update_playlist(f, db, logged_in_api_client, factories): + url = reverse("api:subsonic-update-playlist") + assert url.endswith("updatePlaylist") is True + playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + factories["playlists.PlaylistTrack"](index=0, playlist=playlist) + new_track = factories["music.Track"]() + response = logged_in_api_client.get( + url, + { + "f": f, + "name": "new_name", + "playlistId": playlist.pk, + "songIdToAdd": new_track.pk, + "songIndexToRemove": 0, + }, + ) playlist.refresh_from_db() assert response.status_code == 200 - assert playlist.name == 'new_name' + assert playlist.name == "new_name" assert playlist.playlist_tracks.count() == 1 assert playlist.playlist_tracks.first().track_id == new_track.pk -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_delete_playlist(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-delete-playlist') - assert url.endswith('deletePlaylist') is True - playlist = factories['playlists.Playlist']( - user=logged_in_api_client.user - ) - response = logged_in_api_client.get( - url, {'f': f, 'id': playlist.pk}) + url = reverse("api:subsonic-delete-playlist") + assert url.endswith("deletePlaylist") is True + playlist = factories["playlists.Playlist"](user=logged_in_api_client.user) + response = logged_in_api_client.get(url, {"f": f, "id": playlist.pk}) assert response.status_code == 200 with pytest.raises(playlist.__class__.DoesNotExist): playlist.refresh_from_db() -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_create_playlist(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-create-playlist') - assert url.endswith('createPlaylist') is True - track1 = factories['music.Track']() - track2 = factories['music.Track']() + url = reverse("api:subsonic-create-playlist") + assert url.endswith("createPlaylist") is True + track1 = factories["music.Track"]() + track2 = factories["music.Track"]() response = logged_in_api_client.get( - url, {'f': f, 'name': 'hello', 'songId': [track1.pk, track2.pk]}) + url, {"f": f, "name": "hello", "songId": [track1.pk, track2.pk]} + ) assert response.status_code == 200 - playlist = logged_in_api_client.user.playlists.latest('id') + playlist = logged_in_api_client.user.playlists.latest("id") assert playlist.playlist_tracks.count() == 2 for i, t in enumerate([track1, track2]): plt = playlist.playlist_tracks.get(track=t) assert plt.index == i - assert playlist.name == 'hello' + assert playlist.name == "hello" qs = playlist.__class__.objects.with_tracks_count() assert response.data == { - 'playlist': serializers.get_playlist_detail_data(qs.first()) + "playlist": serializers.get_playlist_detail_data(qs.first()) } -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_music_folders(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-music-folders') - assert url.endswith('getMusicFolders') is True - response = logged_in_api_client.get(url, {'f': f}) + url = reverse("api:subsonic-get-music-folders") + assert url.endswith("getMusicFolders") is True + response = logged_in_api_client.get(url, {"f": f}) assert response.status_code == 200 assert response.data == { - 'musicFolders': { - 'musicFolder': [{ - 'id': 1, - 'name': 'Music' - }] - } + "musicFolders": {"musicFolder": [{"id": 1, "name": "Music"}]} } -@pytest.mark.parametrize('f', ['xml', 'json']) +@pytest.mark.parametrize("f", ["xml", "json"]) def test_get_indexes(f, db, logged_in_api_client, factories): - url = reverse('api:subsonic-get-indexes') - assert url.endswith('getIndexes') is True - artists = factories['music.Artist'].create_batch(size=10) + url = reverse("api:subsonic-get-indexes") + assert url.endswith("getIndexes") is True + factories["music.Artist"].create_batch(size=10) expected = { - 'indexes': serializers.GetArtistsSerializer( + "indexes": serializers.GetArtistsSerializer( music_models.Artist.objects.all() ).data } @@ -394,27 +374,26 @@ def test_get_indexes(f, db, logged_in_api_client, factories): def test_get_cover_art_album(factories, logged_in_api_client): - url = reverse('api:subsonic-get-cover-art') - assert url.endswith('getCoverArt') is True - album = factories['music.Album']() - response = logged_in_api_client.get(url, {'id': 'al-{}'.format(album.pk)}) + url = reverse("api:subsonic-get-cover-art") + assert url.endswith("getCoverArt") is True + album = factories["music.Album"]() + response = logged_in_api_client.get(url, {"id": "al-{}".format(album.pk)}) assert response.status_code == 200 - assert response['Content-Type'] == '' - assert response['X-Accel-Redirect'] == music_views.get_file_path( + assert response["Content-Type"] == "" + assert response["X-Accel-Redirect"] == music_views.get_file_path( album.cover - ).decode('utf-8') + ).decode("utf-8") def test_scrobble(factories, logged_in_api_client): - tf = factories['music.TrackFile']() + tf = factories["music.TrackFile"]() track = tf.track - url = reverse('api:subsonic-scrobble') - assert url.endswith('scrobble') is True - response = logged_in_api_client.get( - url, {'id': track.pk, 'submission': True}) + url = reverse("api:subsonic-scrobble") + assert url.endswith("scrobble") is True + response = logged_in_api_client.get(url, {"id": track.pk, "submission": True}) assert response.status_code == 200 - l = logged_in_api_client.user.listenings.latest('id') - assert l.track == track + listening = logged_in_api_client.user.listenings.latest("id") + assert listening.track == track diff --git a/api/tests/test_acoustid.py b/api/tests/test_acoustid.py index 1f7de9247..ab3dfd1d8 100644 --- a/api/tests/test_acoustid.py +++ b/api/tests/test_acoustid.py @@ -2,33 +2,42 @@ from funkwhale_api.providers.acoustid import get_acoustid_client def test_client_is_configured_with_correct_api_key(preferences): - api_key = 'hello world' - preferences['providers_acoustid__api_key'] = api_key + api_key = "hello world" + preferences["providers_acoustid__api_key"] = api_key client = get_acoustid_client() assert client.api_key == api_key def test_client_returns_raw_results(db, mocker, preferences): - api_key = 'test' - preferences['providers_acoustid__api_key'] = api_key + api_key = "test" + preferences["providers_acoustid__api_key"] = api_key payload = { - 'results': [ - {'id': 'e475bf79-c1ce-4441-bed7-1e33f226c0a2', - 'recordings': [ - {'artists': [ - {'id': '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13', - 'name': 'Binärpilot'}], - 'duration': 268, - 'id': 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb', - 'title': 'Bend'}], - 'score': 0.860825}], - 'status': 'ok' + "results": [ + { + "id": "e475bf79-c1ce-4441-bed7-1e33f226c0a2", + "recordings": [ + { + "artists": [ + { + "id": "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13", + "name": "Binärpilot", + } + ], + "duration": 268, + "id": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb", + "title": "Bend", + } + ], + "score": 0.860825, + } + ], + "status": "ok", } - m = mocker.patch('acoustid.match', return_value=payload) + m = mocker.patch("acoustid.match", return_value=payload) client = get_acoustid_client() - response = client.match('/tmp/noopfile.mp3') + response = client.match("/tmp/noopfile.mp3") assert response == payload - m.assert_called_once_with('test', '/tmp/noopfile.mp3', parse=False) + m.assert_called_once_with("test", "/tmp/noopfile.mp3", parse=False) diff --git a/api/tests/test_downloader.py b/api/tests/test_downloader.py index ede7bb16c..0a4134393 100644 --- a/api/tests/test_downloader.py +++ b/api/tests/test_downloader.py @@ -5,7 +5,7 @@ from funkwhale_api import downloader def test_can_download_audio_from_youtube_url_to_vorbis(tmpdir): data = downloader.download( - 'https://www.youtube.com/watch?v=tPEE9ZwTmy0', - target_directory=tmpdir) - assert data['audio_file_path'] == os.path.join(tmpdir, 'tPEE9ZwTmy0.ogg') - assert os.path.exists(data['audio_file_path']) + "https://www.youtube.com/watch?v=tPEE9ZwTmy0", target_directory=tmpdir + ) + assert data["audio_file_path"] == os.path.join(tmpdir, "tPEE9ZwTmy0.ogg") + assert os.path.exists(data["audio_file_path"]) diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index da3d1959c..67f6c489d 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -1,201 +1,166 @@ -import pytest import datetime import os -import uuid +import pytest from django.core.management import call_command from django.core.management.base import CommandError from funkwhale_api.providers.audiofile import tasks -from funkwhale_api.music import tasks as music_tasks -DATA_DIR = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - 'files' -) +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files") def test_can_create_track_from_file_metadata_no_mbid(db, mocker): metadata = { - 'artist': ['Test artist'], - 'album': ['Test album'], - 'title': ['Test track'], - 'TRACKNUMBER': ['4'], - 'date': ['2012-08-15'], + "artist": ["Test artist"], + "album": ["Test album"], + "title": ["Test track"], + "TRACKNUMBER": ["4"], + "date": ["2012-08-15"], } - m1 = mocker.patch('mutagen.File', return_value=metadata) - m2 = mocker.patch( - 'funkwhale_api.music.metadata.Metadata.get_file_type', - return_value='OggVorbis', + mocker.patch("mutagen.File", return_value=metadata) + mocker.patch( + "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" ) - track = tasks.import_track_data_from_path( - os.path.join(DATA_DIR, 'dummy_file.ogg')) + track = tasks.import_track_data_from_path(os.path.join(DATA_DIR, "dummy_file.ogg")) - assert track.title == metadata['title'][0] + assert track.title == metadata["title"][0] assert track.mbid is None assert track.position == 4 - assert track.album.title == metadata['album'][0] + assert track.album.title == metadata["album"][0] assert track.album.mbid is None assert track.album.release_date == datetime.date(2012, 8, 15) - assert track.artist.name == metadata['artist'][0] + assert track.artist.name == metadata["artist"][0] assert track.artist.mbid is None def test_can_create_track_from_file_metadata_mbid(factories, mocker): - album = factories['music.Album']() + album = factories["music.Album"]() mocker.patch( - 'funkwhale_api.music.models.Album.get_or_create_from_api', + "funkwhale_api.music.models.Album.get_or_create_from_api", return_value=(album, True), ) album_data = { - 'release': { - 'id': album.mbid, - 'medium-list': [ + "release": { + "id": album.mbid, + "medium-list": [ { - 'track-list': [ + "track-list": [ { - 'id': '03baca8b-855a-3c05-8f3d-d3235287d84d', - 'position': '4', - 'number': '4', - 'recording': { - 'id': '2109e376-132b-40ad-b993-2bb6812e19d4', - 'title': 'Teen Age Riot', + "id": "03baca8b-855a-3c05-8f3d-d3235287d84d", + "position": "4", + "number": "4", + "recording": { + "id": "2109e376-132b-40ad-b993-2bb6812e19d4", + "title": "Teen Age Riot", }, } ], - 'track-count': 1 + "track-count": 1, } ], } } - mocker.patch( - 'funkwhale_api.musicbrainz.api.releases.get', - return_value=album_data) - track_data = album_data['release']['medium-list'][0]['track-list'][0] + mocker.patch("funkwhale_api.musicbrainz.api.releases.get", return_value=album_data) + track_data = album_data["release"]["medium-list"][0]["track-list"][0] metadata = { - 'musicbrainz_albumid': [album.mbid], - 'musicbrainz_trackid': [track_data['recording']['id']], + "musicbrainz_albumid": [album.mbid], + "musicbrainz_trackid": [track_data["recording"]["id"]], } - m1 = mocker.patch('mutagen.File', return_value=metadata) - m2 = mocker.patch( - 'funkwhale_api.music.metadata.Metadata.get_file_type', - return_value='OggVorbis', + mocker.patch("mutagen.File", return_value=metadata) + mocker.patch( + "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis" ) - track = tasks.import_track_data_from_path( - os.path.join(DATA_DIR, 'dummy_file.ogg')) + track = tasks.import_track_data_from_path(os.path.join(DATA_DIR, "dummy_file.ogg")) - assert track.title == track_data['recording']['title'] - assert track.mbid == track_data['recording']['id'] + assert track.title == track_data["recording"]["title"] + assert track.mbid == track_data["recording"]["id"] assert track.position == 4 assert track.album == album assert track.artist == album.artist def test_management_command_requires_a_valid_username(factories, mocker): - path = os.path.join(DATA_DIR, 'dummy_file.ogg') - user = factories['users.User'](username='me') + path = os.path.join(DATA_DIR, "dummy_file.ogg") + factories["users.User"](username="me") mocker.patch( - 'funkwhale_api.providers.audiofile.management.commands.import_files.Command.do_import', # noqa - return_value=(mocker.MagicMock(), [])) + "funkwhale_api.providers.audiofile.management.commands.import_files.Command.do_import", # noqa + return_value=(mocker.MagicMock(), []), + ) with pytest.raises(CommandError): - call_command('import_files', path, username='not_me', interactive=False) - call_command('import_files', path, username='me', interactive=False) + call_command("import_files", path, username="not_me", interactive=False) + call_command("import_files", path, username="me", interactive=False) def test_in_place_import_only_from_music_dir(factories, settings): - user = factories['users.User'](username='me') - settings.MUSIC_DIRECTORY_PATH = '/nope' - path = os.path.join(DATA_DIR, 'dummy_file.ogg') + factories["users.User"](username="me") + settings.MUSIC_DIRECTORY_PATH = "/nope" + path = os.path.join(DATA_DIR, "dummy_file.ogg") with pytest.raises(CommandError): call_command( - 'import_files', - path, - in_place=True, - username='me', - interactive=False + "import_files", path, in_place=True, username="me", interactive=False ) def test_import_files_creates_a_batch_and_job(factories, mocker): - m = mocker.patch('funkwhale_api.music.tasks.import_job_run') - user = factories['users.User'](username='me') - path = os.path.join(DATA_DIR, 'dummy_file.ogg') - call_command( - 'import_files', - path, - username='me', - async=False, - interactive=False) + m = mocker.patch("funkwhale_api.music.tasks.import_job_run") + user = factories["users.User"](username="me") + path = os.path.join(DATA_DIR, "dummy_file.ogg") + call_command("import_files", path, username="me", async=False, interactive=False) - batch = user.imports.latest('id') - assert batch.source == 'shell' + batch = user.imports.latest("id") + assert batch.source == "shell" assert batch.jobs.count() == 1 job = batch.jobs.first() - assert job.status == 'pending' - with open(path, 'rb') as f: + assert job.status == "pending" + with open(path, "rb") as f: assert job.audio_file.read() == f.read() - assert job.source == 'file://' + path - m.assert_called_once_with( - import_job_id=job.pk, - use_acoustid=False) + assert job.source == "file://" + path + m.assert_called_once_with(import_job_id=job.pk, use_acoustid=False) def test_import_files_skip_if_path_already_imported(factories, mocker): - user = factories['users.User'](username='me') - path = os.path.join(DATA_DIR, 'dummy_file.ogg') - existing = factories['music.TrackFile']( - source='file://{}'.format(path)) + user = factories["users.User"](username="me") + path = os.path.join(DATA_DIR, "dummy_file.ogg") + factories["music.TrackFile"](source="file://{}".format(path)) - call_command( - 'import_files', - path, - username='me', - async=False, - interactive=False) + call_command("import_files", path, username="me", async=False, interactive=False) assert user.imports.count() == 0 def test_import_files_works_with_utf8_file_name(factories, mocker): - m = mocker.patch('funkwhale_api.music.tasks.import_job_run') - user = factories['users.User'](username='me') - path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg') - call_command( - 'import_files', - path, - username='me', - async=False, - interactive=False) - batch = user.imports.latest('id') + m = mocker.patch("funkwhale_api.music.tasks.import_job_run") + user = factories["users.User"](username="me") + path = os.path.join(DATA_DIR, "utf8-éà◌.ogg") + call_command("import_files", path, username="me", async=False, interactive=False) + batch = user.imports.latest("id") job = batch.jobs.first() - m.assert_called_once_with( - import_job_id=job.pk, - use_acoustid=False) + m.assert_called_once_with(import_job_id=job.pk, use_acoustid=False) def test_import_files_in_place(factories, mocker, settings): settings.MUSIC_DIRECTORY_PATH = DATA_DIR - m = mocker.patch('funkwhale_api.music.tasks.import_job_run') - user = factories['users.User'](username='me') - path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg') + m = mocker.patch("funkwhale_api.music.tasks.import_job_run") + user = factories["users.User"](username="me") + path = os.path.join(DATA_DIR, "utf8-éà◌.ogg") call_command( - 'import_files', + "import_files", path, - username='me', + username="me", async=False, in_place=True, - interactive=False) - batch = user.imports.latest('id') + interactive=False, + ) + batch = user.imports.latest("id") job = batch.jobs.first() assert bool(job.audio_file) is False - m.assert_called_once_with( - import_job_id=job.pk, - use_acoustid=False) + m.assert_called_once_with(import_job_id=job.pk, use_acoustid=False) def test_storage_rename_utf_8_files(factories): - tf = factories['music.TrackFile'](audio_file__filename='été.ogg') - assert tf.audio_file.name.endswith('ete.ogg') + tf = factories["music.TrackFile"](audio_file__filename="été.ogg") + assert tf.audio_file.name.endswith("ete.ogg") diff --git a/api/tests/test_jwt_querystring.py b/api/tests/test_jwt_querystring.py index f18e6b729..18a673fb4 100644 --- a/api/tests/test_jwt_querystring.py +++ b/api/tests/test_jwt_querystring.py @@ -5,18 +5,15 @@ jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER -def test_can_authenticate_using_token_param_in_url( - factories, preferences, client): - user = factories['users.User']() - preferences['common__api_authentication_required'] = True - url = reverse('api:v1:tracks-list') +def test_can_authenticate_using_token_param_in_url(factories, preferences, client): + user = factories["users.User"]() + preferences["common__api_authentication_required"] = True + url = reverse("api:v1:tracks-list") response = client.get(url) assert response.status_code == 401 payload = jwt_payload_handler(user) token = jwt_encode_handler(payload) - response = client.get(url, data={ - 'jwt': token - }) + response = client.get(url, data={"jwt": token}) assert response.status_code == 200 diff --git a/api/tests/test_tasks.py b/api/tests/test_tasks.py index 16088d53f..d46c3a3fb 100644 --- a/api/tests/test_tasks.py +++ b/api/tests/test_tasks.py @@ -10,24 +10,25 @@ class Dummy: def test_require_instance_decorator(factories, mocker): - user = factories['users.User']() + user = factories["users.User"]() - @celery.require_instance(user.__class__, 'user') + @celery.require_instance(user.__class__, "user") def t(user): Dummy.noop(user) - m = mocker.patch.object(Dummy, 'noop') + m = mocker.patch.object(Dummy, "noop") t(user_id=user.pk) m.assert_called_once_with(user) def test_require_instance_decorator_accepts_qs(factories, mocker): - user = factories['users.User'](is_active=False) + user = factories["users.User"](is_active=False) qs = user.__class__.objects.filter(is_active=True) - @celery.require_instance(qs, 'user') + @celery.require_instance(qs, "user") def t(user): pass + with pytest.raises(user.__class__.DoesNotExist): t(user_id=user.pk) diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py index 7ab6256da..cb5559ce1 100644 --- a/api/tests/test_youtube.py +++ b/api/tests/test_youtube.py @@ -1,6 +1,7 @@ -import json from collections import OrderedDict + from django.urls import reverse + from funkwhale_api.providers.youtube.client import client from .data import youtube as api_data @@ -8,35 +9,36 @@ from .data import youtube as api_data def test_can_get_search_results_from_youtube(mocker): mocker.patch( - 'funkwhale_api.providers.youtube.client._do_search', - return_value=api_data.search['8 bit adventure']) - query = '8 bit adventure' + "funkwhale_api.providers.youtube.client._do_search", + return_value=api_data.search["8 bit adventure"], + ) + query = "8 bit adventure" results = client.search(query) - assert results[0]['id']['videoId'] == '0HxZn6CzOIo' - assert results[0]['snippet']['title'] == 'AdhesiveWombat - 8 Bit Adventure' - assert results[0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo' + assert results[0]["id"]["videoId"] == "0HxZn6CzOIo" + assert results[0]["snippet"]["title"] == "AdhesiveWombat - 8 Bit Adventure" + assert results[0]["full_url"] == "https://www.youtube.com/watch?v=0HxZn6CzOIo" -def test_can_get_search_results_from_funkwhale( - preferences, mocker, api_client, db): - preferences['common__api_authentication_required'] = False +def test_can_get_search_results_from_funkwhale(preferences, mocker, api_client, db): + preferences["common__api_authentication_required"] = False mocker.patch( - 'funkwhale_api.providers.youtube.client._do_search', - return_value=api_data.search['8 bit adventure']) - query = '8 bit adventure' - url = reverse('api:v1:providers:youtube:search') - response = api_client.get(url, {'query': query}) + "funkwhale_api.providers.youtube.client._do_search", + return_value=api_data.search["8 bit adventure"], + ) + query = "8 bit adventure" + url = reverse("api:v1:providers:youtube:search") + response = api_client.get(url, {"query": query}) # we should cast the youtube result to something more generic expected = { "id": "0HxZn6CzOIo", "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", "type": "youtube#video", - "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...", + "description": "Description", "channelId": "UCps63j3krzAG4OyXeEyuhFw", "title": "AdhesiveWombat - 8 Bit Adventure", "channelTitle": "AdhesiveWombat", "publishedAt": "2012-08-22T18:41:03.000Z", - "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg" + "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg", } assert response.data[0] == expected @@ -44,55 +46,51 @@ def test_can_get_search_results_from_funkwhale( def test_can_send_multiple_queries_at_once(mocker): mocker.patch( - 'funkwhale_api.providers.youtube.client._do_search', + "funkwhale_api.providers.youtube.client._do_search", side_effect=[ - api_data.search['8 bit adventure'], - api_data.search['system of a down toxicity'], - ] + api_data.search["8 bit adventure"], + api_data.search["system of a down toxicity"], + ], ) queries = OrderedDict() - queries['1'] = { - 'q': '8 bit adventure', - } - queries['2'] = { - 'q': 'system of a down toxicity', - } + queries["1"] = {"q": "8 bit adventure"} + queries["2"] = {"q": "system of a down toxicity"} results = client.search_multiple(queries) - assert results['1'][0]['id']['videoId'] == '0HxZn6CzOIo' - assert results['1'][0]['snippet']['title'] == 'AdhesiveWombat - 8 Bit Adventure' - assert results['1'][0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo' - assert results['2'][0]['id']['videoId'] == 'BorYwGi2SJc' - assert results['2'][0]['snippet']['title'] == 'System of a Down: Toxicity' - assert results['2'][0]['full_url'] == 'https://www.youtube.com/watch?v=BorYwGi2SJc' + assert results["1"][0]["id"]["videoId"] == "0HxZn6CzOIo" + assert results["1"][0]["snippet"]["title"] == "AdhesiveWombat - 8 Bit Adventure" + assert results["1"][0]["full_url"] == "https://www.youtube.com/watch?v=0HxZn6CzOIo" + assert results["2"][0]["id"]["videoId"] == "BorYwGi2SJc" + assert results["2"][0]["snippet"]["title"] == "System of a Down: Toxicity" + assert results["2"][0]["full_url"] == "https://www.youtube.com/watch?v=BorYwGi2SJc" def test_can_send_multiple_queries_at_once_from_funwkhale( - preferences, mocker, db, api_client): - preferences['common__api_authentication_required'] = False + preferences, mocker, db, api_client +): + preferences["common__api_authentication_required"] = False mocker.patch( - 'funkwhale_api.providers.youtube.client._do_search', - return_value=api_data.search['8 bit adventure']) + "funkwhale_api.providers.youtube.client._do_search", + return_value=api_data.search["8 bit adventure"], + ) queries = OrderedDict() - queries['1'] = { - 'q': '8 bit adventure', - } + queries["1"] = {"q": "8 bit adventure"} expected = { "id": "0HxZn6CzOIo", "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo", "type": "youtube#video", - "description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...", + "description": "Description", "channelId": "UCps63j3krzAG4OyXeEyuhFw", "title": "AdhesiveWombat - 8 Bit Adventure", "channelTitle": "AdhesiveWombat", "publishedAt": "2012-08-22T18:41:03.000Z", - "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg" + "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg", } - url = reverse('api:v1:providers:youtube:searchs') - response = api_client.post(url, queries, format='json') + url = reverse("api:v1:providers:youtube:searchs") + response = api_client.post(url, queries, format="json") - assert expected == response.data['1'][0] + assert expected == response.data["1"][0] diff --git a/api/tests/users/test_activity.py b/api/tests/users/test_activity.py index 26d0b11f8..cfacff997 100644 --- a/api/tests/users/test_activity.py +++ b/api/tests/users/test_activity.py @@ -2,13 +2,14 @@ from funkwhale_api.users import serializers def test_get_user_activity_url(settings, factories): - user = factories['users.User']() - assert user.get_activity_url() == '{}/@{}'.format( - settings.FUNKWHALE_URL, user.username) + user = factories["users.User"]() + assert user.get_activity_url() == "{}/@{}".format( + settings.FUNKWHALE_URL, user.username + ) def test_activity_user_serializer(factories): - user = factories['users.User']() + user = factories["users.User"]() expected = { "type": "Person", diff --git a/api/tests/users/test_admin.py b/api/tests/users/test_admin.py index 7645a0295..03b316eb0 100644 --- a/api/tests/users/test_admin.py +++ b/api/tests/users/test_admin.py @@ -3,28 +3,24 @@ from funkwhale_api.users.admin import MyUserCreationForm def test_clean_username_success(db): # Instantiate the form with a new username - form = MyUserCreationForm({ - 'username': 'alamode', - 'password1': '123456', - 'password2': '123456', - }) + form = MyUserCreationForm( + {"username": "alamode", "password1": "123456", "password2": "123456"} + ) # Run is_valid() to trigger the validation valid = form.is_valid() assert valid # Run the actual clean_username method username = form.clean_username() - assert 'alamode' == username + assert "alamode" == username def test_clean_username_false(factories): - user = factories['users.User']() + user = factories["users.User"]() # Instantiate the form with the same username as self.user - form = MyUserCreationForm({ - 'username': user.username, - 'password1': '123456', - 'password2': '123456', - }) + form = MyUserCreationForm( + {"username": user.username, "password1": "123456", "password2": "123456"} + ) # Run is_valid() to trigger the validation, which is going to fail # because the username is already taken valid = form.is_valid() @@ -32,4 +28,4 @@ def test_clean_username_false(factories): # The form.errors dict should contain a single error called 'username' assert len(form.errors) == 1 - assert 'username' in form.errors + assert "username" in form.errors diff --git a/api/tests/users/test_jwt.py b/api/tests/users/test_jwt.py index d264494e5..83de757c8 100644 --- a/api/tests/users/test_jwt.py +++ b/api/tests/users/test_jwt.py @@ -1,13 +1,10 @@ import pytest -import uuid - from jwt.exceptions import DecodeError from rest_framework_jwt.settings import api_settings -from funkwhale_api.users.models import User def test_can_invalidate_token_when_changing_user_secret_key(factories): - user = factories['users.User']() + user = factories["users.User"]() u1 = user.secret_key jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index 42123b5e8..c73a4a1b1 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -4,26 +4,26 @@ from funkwhale_api.users import models def test__str__(factories): - user = factories['users.User'](username='hello') - assert user.__str__() == 'hello' + user = factories["users.User"](username="hello") + assert user.__str__() == "hello" def test_changing_password_updates_subsonic_api_token_no_token(factories): - user = factories['users.User'](subsonic_api_token=None) - user.set_password('new') + user = factories["users.User"](subsonic_api_token=None) + user.set_password("new") assert user.subsonic_api_token is None def test_changing_password_updates_subsonic_api_token(factories): - user = factories['users.User'](subsonic_api_token='test') - user.set_password('new') + user = factories["users.User"](subsonic_api_token="test") + user.set_password("new") assert user.subsonic_api_token is not None - assert user.subsonic_api_token != 'test' + assert user.subsonic_api_token != "test" def test_get_permissions_superuser(factories): - user = factories['users.User'](is_superuser=True) + user = factories["users.User"](is_superuser=True) perms = user.get_permissions() for p in models.PERMISSIONS: @@ -31,44 +31,50 @@ def test_get_permissions_superuser(factories): def test_get_permissions_regular(factories): - user = factories['users.User'](permission_library=True) + user = factories["users.User"](permission_library=True) perms = user.get_permissions() for p in models.PERMISSIONS: - if p == 'library': + if p == "library": assert perms[p] is True else: assert perms[p] is False def test_get_permissions_default(factories, preferences): - preferences['users__default_permissions'] = ['upload', 'federation'] - user = factories['users.User']() + preferences["users__default_permissions"] = ["upload", "federation"] + user = factories["users.User"]() perms = user.get_permissions() - assert perms['upload'] is True - assert perms['federation'] is True - assert perms['library'] is False - assert perms['settings'] is False + assert perms["upload"] is True + assert perms["federation"] is True + assert perms["library"] is False + assert perms["settings"] is False -@pytest.mark.parametrize('args,perms,expected', [ - ({'is_superuser': True}, ['federation', 'library'], True), - ({'is_superuser': False}, ['federation'], False), - ({'permission_library': True}, ['library'], True), - ({'permission_library': True}, ['library', 'federation'], False), -]) +@pytest.mark.parametrize( + "args,perms,expected", + [ + ({"is_superuser": True}, ["federation", "library"], True), + ({"is_superuser": False}, ["federation"], False), + ({"permission_library": True}, ["library"], True), + ({"permission_library": True}, ["library", "federation"], False), + ], +) def test_has_permissions_and(args, perms, expected, factories): - user = factories['users.User'](**args) - assert user.has_permissions(*perms, operator='and') is expected + user = factories["users.User"](**args) + assert user.has_permissions(*perms, operator="and") is expected -@pytest.mark.parametrize('args,perms,expected', [ - ({'is_superuser': True}, ['federation', 'library'], True), - ({'is_superuser': False}, ['federation'], False), - ({'permission_library': True}, ['library', 'federation'], True), - ({'permission_library': True}, ['federation'], False), -]) +@pytest.mark.parametrize( + "args,perms,expected", + [ + ({"is_superuser": True}, ["federation", "library"], True), + ({"is_superuser": False}, ["federation"], False), + ({"permission_library": True}, ["library", "federation"], True), + ({"permission_library": True}, ["federation"], False), + ], +) def test_has_permissions_or(args, perms, expected, factories): - user = factories['users.User'](**args) - assert user.has_permissions(*perms, operator='or') is expected + user = factories["users.User"](**args) + assert user.has_permissions(*perms, operator="or") is expected diff --git a/api/tests/users/test_permissions.py b/api/tests/users/test_permissions.py index 518ccd1c8..7f72138f4 100644 --- a/api/tests/users/test_permissions.py +++ b/api/tests/users/test_permissions.py @@ -7,76 +7,86 @@ from funkwhale_api.users import permissions def test_has_user_permission_no_user(api_request): view = APIView.as_view() permission = permissions.HasUserPermission() - request = api_request.get('/') + request = api_request.get("/") assert permission.has_permission(request, view) is False def test_has_user_permission_anonymous(anonymous_user, api_request): view = APIView.as_view() permission = permissions.HasUserPermission() - request = api_request.get('/') - setattr(request, 'user', anonymous_user) + request = api_request.get("/") + setattr(request, "user", anonymous_user) assert permission.has_permission(request, view) is False -@pytest.mark.parametrize('value', [True, False]) +@pytest.mark.parametrize("value", [True, False]) def test_has_user_permission_logged_in_single(value, factories, api_request): - user = factories['users.User'](permission_federation=value) + user = factories["users.User"](permission_federation=value) class View(APIView): - required_permissions = ['federation'] + required_permissions = ["federation"] + view = View() permission = permissions.HasUserPermission() - request = api_request.get('/') - setattr(request, 'user', user) + request = api_request.get("/") + setattr(request, "user", user) result = permission.has_permission(request, view) - assert result == user.has_permissions('federation') == value + assert result == user.has_permissions("federation") == value -@pytest.mark.parametrize('federation,library,expected', [ - (True, False, False), - (False, True, False), - (False, False, False), - (True, True, True), -]) +@pytest.mark.parametrize( + "federation,library,expected", + [ + (True, False, False), + (False, True, False), + (False, False, False), + (True, True, True), + ], +) def test_has_user_permission_logged_in_multiple_and( - federation, library, expected, factories, api_request): - user = factories['users.User']( - permission_federation=federation, - permission_library=library, + federation, library, expected, factories, api_request +): + user = factories["users.User"]( + permission_federation=federation, permission_library=library ) class View(APIView): - required_permissions = ['federation', 'library'] - permission_operator = 'and' + required_permissions = ["federation", "library"] + permission_operator = "and" + view = View() permission = permissions.HasUserPermission() - request = api_request.get('/') - setattr(request, 'user', user) + request = api_request.get("/") + setattr(request, "user", user) result = permission.has_permission(request, view) - assert result == user.has_permissions('federation', 'library') == expected + assert result == user.has_permissions("federation", "library") == expected -@pytest.mark.parametrize('federation,library,expected', [ - (True, False, True), - (False, True, True), - (False, False, False), - (True, True, True), -]) +@pytest.mark.parametrize( + "federation,library,expected", + [ + (True, False, True), + (False, True, True), + (False, False, False), + (True, True, True), + ], +) def test_has_user_permission_logged_in_multiple_or( - federation, library, expected, factories, api_request): - user = factories['users.User']( - permission_federation=federation, - permission_library=library, + federation, library, expected, factories, api_request +): + user = factories["users.User"]( + permission_federation=federation, permission_library=library ) class View(APIView): - required_permissions = ['federation', 'library'] - permission_operator = 'or' + required_permissions = ["federation", "library"] + permission_operator = "or" + view = View() permission = permissions.HasUserPermission() - request = api_request.get('/') - setattr(request, 'user', user) + request = api_request.get("/") + setattr(request, "user", user) result = permission.has_permission(request, view) - assert result == user.has_permissions( - 'federation', 'library', operator='or') == expected + has_permission_result = user.has_permissions("federation", "library", operator="or") + + assert result == has_permission_result == expected diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 6418889ce..00272c2ae 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -1,98 +1,87 @@ -import json import pytest - -from django.test import RequestFactory from django.urls import reverse from funkwhale_api.users.models import User def test_can_create_user_via_api(preferences, api_client, db): - url = reverse('rest_register') + url = reverse("rest_register") data = { - 'username': 'test1', - 'email': 'test1@test.com', - 'password1': 'testtest', - 'password2': 'testtest', + "username": "test1", + "email": "test1@test.com", + "password1": "testtest", + "password2": "testtest", } - preferences['users__registration_enabled'] = True + preferences["users__registration_enabled"] = True response = api_client.post(url, data) assert response.status_code == 201 - u = User.objects.get(email='test1@test.com') - assert u.username == 'test1' + u = User.objects.get(email="test1@test.com") + assert u.username == "test1" def test_can_restrict_usernames(settings, preferences, db, api_client): - url = reverse('rest_register') - preferences['users__registration_enabled'] = True - settings.USERNAME_BLACKLIST = ['funkwhale'] + url = reverse("rest_register") + preferences["users__registration_enabled"] = True + settings.USERNAME_BLACKLIST = ["funkwhale"] data = { - 'username': 'funkwhale', - 'email': 'contact@funkwhale.io', - 'password1': 'testtest', - 'password2': 'testtest', + "username": "funkwhale", + "email": "contact@funkwhale.io", + "password1": "testtest", + "password2": "testtest", } response = api_client.post(url, data) assert response.status_code == 400 - assert 'username' in response.data + assert "username" in response.data def test_can_disable_registration_view(preferences, api_client, db): - url = reverse('rest_register') + url = reverse("rest_register") data = { - 'username': 'test1', - 'email': 'test1@test.com', - 'password1': 'testtest', - 'password2': 'testtest', + "username": "test1", + "email": "test1@test.com", + "password1": "testtest", + "password2": "testtest", } - preferences['users__registration_enabled'] = False + preferences["users__registration_enabled"] = False response = api_client.post(url, data) assert response.status_code == 403 def test_can_fetch_data_from_api(api_client, factories): - url = reverse('api:v1:users:users-me') + url = reverse("api:v1:users:users-me") response = api_client.get(url) # login required assert response.status_code == 401 - user = factories['users.User']( - permission_library=True - ) - api_client.login(username=user.username, password='test') + user = factories["users.User"](permission_library=True) + api_client.login(username=user.username, password="test") response = api_client.get(url) assert response.status_code == 200 - assert response.data['username'] == user.username - assert response.data['is_staff'] == user.is_staff - assert response.data['is_superuser'] == user.is_superuser - assert response.data['email'] == user.email - assert response.data['name'] == user.name - assert response.data['permissions'] == user.get_permissions() + assert response.data["username"] == user.username + assert response.data["is_staff"] == user.is_staff + assert response.data["is_superuser"] == user.is_superuser + assert response.data["email"] == user.email + assert response.data["name"] == user.name + assert response.data["permissions"] == user.get_permissions() def test_can_get_token_via_api(api_client, factories): - user = factories['users.User']() - url = reverse('api:v1:token') - payload = { - 'username': user.username, - 'password': 'test' - } + user = factories["users.User"]() + url = reverse("api:v1:token") + payload = {"username": user.username, "password": "test"} response = api_client.post(url, payload) assert response.status_code == 200 - assert 'token' in response.data + assert "token" in response.data def test_can_get_token_via_api_inactive(api_client, factories): - user = factories['users.User'](is_active=False) - url = reverse('api:v1:token') - payload = { - 'username': user.username, - 'password': 'test' - } + user = factories["users.User"](is_active=False) + url = reverse("api:v1:token") + payload = {"username": user.username, "password": "test"} response = api_client.post(url, payload) assert response.status_code == 400 @@ -100,36 +89,29 @@ def test_can_get_token_via_api_inactive(api_client, factories): def test_can_refresh_token_via_api(api_client, factories, mocker): # first, we get a token - user = factories['users.User']() - url = reverse('api:v1:token') - payload = { - 'username': user.username, - 'password': 'test' - } + user = factories["users.User"]() + url = reverse("api:v1:token") + payload = {"username": user.username, "password": "test"} response = api_client.post(url, payload) assert response.status_code == 200 - token = response.data['token'] - url = reverse('api:v1:token_refresh') - response = api_client.post(url, {'token': token}) + token = response.data["token"] + url = reverse("api:v1:token_refresh") + response = api_client.post(url, {"token": token}) assert response.status_code == 200 - assert 'token' in response.data + assert "token" in response.data def test_changing_password_updates_secret_key(logged_in_api_client): user = logged_in_api_client.user password = user.password secret_key = user.secret_key - payload = { - 'old_password': 'test', - 'new_password1': 'new', - 'new_password2': 'new', - } - url = reverse('change_password') + payload = {"old_password": "test", "new_password1": "new", "new_password2": "new"} + url = reverse("change_password") - response = logged_in_api_client.post(url, payload) + logged_in_api_client.post(url, payload) user.refresh_from_db() @@ -137,14 +119,11 @@ def test_changing_password_updates_secret_key(logged_in_api_client): assert user.password != password -def test_can_request_password_reset( - factories, api_client, mailoutbox): - user = factories['users.User']() - payload = { - 'email': user.email, - } +def test_can_request_password_reset(factories, api_client, mailoutbox): + user = factories["users.User"]() + payload = {"email": user.email} emails = len(mailoutbox) - url = reverse('rest_password_reset') + url = reverse("rest_password_reset") response = api_client.post(url, payload) assert response.status_code == 200 @@ -153,86 +132,58 @@ def test_can_request_password_reset( def test_user_can_patch_his_own_settings(logged_in_api_client): user = logged_in_api_client.user - payload = { - 'privacy_level': 'me', - } - url = reverse( - 'api:v1:users:users-detail', - kwargs={'username': user.username}) + payload = {"privacy_level": "me"} + url = reverse("api:v1:users:users-detail", kwargs={"username": user.username}) response = logged_in_api_client.patch(url, payload) assert response.status_code == 200 user.refresh_from_db() - assert user.privacy_level == 'me' + assert user.privacy_level == "me" def test_user_can_request_new_subsonic_token(logged_in_api_client): user = logged_in_api_client.user - user.subsonic_api_token = 'test' + user.subsonic_api_token = "test" user.save() url = reverse( - 'api:v1:users:users-subsonic-token', - kwargs={'username': user.username}) + "api:v1:users:users-subsonic-token", kwargs={"username": user.username} + ) response = logged_in_api_client.post(url) assert response.status_code == 200 user.refresh_from_db() - assert user.subsonic_api_token != 'test' + assert user.subsonic_api_token != "test" assert user.subsonic_api_token is not None - assert response.data == { - 'subsonic_api_token': user.subsonic_api_token - } + assert response.data == {"subsonic_api_token": user.subsonic_api_token} -def test_user_can_get_new_subsonic_token(logged_in_api_client): +def test_user_can_get_subsonic_token(logged_in_api_client): user = logged_in_api_client.user - user.subsonic_api_token = 'test' + user.subsonic_api_token = "test" user.save() url = reverse( - 'api:v1:users:users-subsonic-token', - kwargs={'username': user.username}) + "api:v1:users:users-subsonic-token", kwargs={"username": user.username} + ) response = logged_in_api_client.get(url) assert response.status_code == 200 - assert response.data == { - 'subsonic_api_token': 'test' - } - - -def test_user_can_request_new_subsonic_token(logged_in_api_client): - user = logged_in_api_client.user - user.subsonic_api_token = 'test' - user.save() - - url = reverse( - 'api:v1:users:users-subsonic-token', - kwargs={'username': user.username}) - - response = logged_in_api_client.post(url) - - assert response.status_code == 200 - user.refresh_from_db() - assert user.subsonic_api_token != 'test' - assert user.subsonic_api_token is not None - assert response.data == { - 'subsonic_api_token': user.subsonic_api_token - } + assert response.data == {"subsonic_api_token": "test"} def test_user_can_delete_subsonic_token(logged_in_api_client): user = logged_in_api_client.user - user.subsonic_api_token = 'test' + user.subsonic_api_token = "test" user.save() url = reverse( - 'api:v1:users:users-subsonic-token', - kwargs={'username': user.username}) + "api:v1:users:users-subsonic-token", kwargs={"username": user.username} + ) response = logged_in_api_client.delete(url) @@ -241,16 +192,11 @@ def test_user_can_delete_subsonic_token(logged_in_api_client): assert user.subsonic_api_token is None -@pytest.mark.parametrize('method', ['put', 'patch']) -def test_user_cannot_patch_another_user( - method, logged_in_api_client, factories): - user = factories['users.User']() - payload = { - 'privacy_level': 'me', - } - url = reverse( - 'api:v1:users:users-detail', - kwargs={'username': user.username}) +@pytest.mark.parametrize("method", ["put", "patch"]) +def test_user_cannot_patch_another_user(method, logged_in_api_client, factories): + user = factories["users.User"]() + payload = {"privacy_level": "me"} + url = reverse("api:v1:users:users-detail", kwargs={"username": user.username}) handler = getattr(logged_in_api_client, method) response = handler(url, payload) diff --git a/deploy/apache.conf b/deploy/apache.conf index 5b74efecd..5f86db7bd 100644 --- a/deploy/apache.conf +++ b/deploy/apache.conf @@ -4,12 +4,10 @@ Define funkwhale-sn funkwhale.yourdomain.com # Following variables should be modified according to your setup and if you # use different configuration than what is described in our installation guide. Define funkwhale-api http://localhost:5000 +Define funkwhale-api-ws ws://localhost:5000 Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music -# websockets are not working yet -# Define funkwhale-api-ws ws://localhost:5000 - -# HTTP request redirected to HTTPS +# HTTP requests redirected to HTTPS ServerName ${funkwhale-sn} @@ -22,7 +20,6 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music Options None Require all granted - @@ -39,13 +36,15 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music # lines and move the configuration to the previous server block if you # don't want to run funkwhale behind https (this is not recommanded) # have a look here for let's encrypt configuration: - # https://certbot.eff.org/all-instructions/#debian-9-stretch-nginx + # https://certbot.eff.org/lets-encrypt/debianstretch-apache.html SSLEngine on SSLProxyEngine On SSLCertificateFile /etc/letsencrypt/live/${funkwhale-sn}/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/${funkwhale-sn}/privkey.pem Include /etc/letsencrypt/options-ssl-apache.conf + # Tell the api that the client is using https + RequestHeader set X-Forwarded-Proto "https" DocumentRoot /srv/funkwhale/front/dist @@ -69,8 +68,8 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music Allow from all - # Activating WebSockets (not working) - # ProxyPass "/api/v1/instance/activity" "ws://localhost:5000/api/v1/instance/activity" + # Activating WebSockets + ProxyPass "/api/v1/instance/activity" ${funkwhale-api-ws}/api/v1/instance/activity # similar to nginx 'client_max_body_size 30M;' @@ -112,6 +111,12 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music Require all granted + + Options FollowSymLinks + AllowOverride None + Require all granted + + # XSendFile is serving audio files # WARNING : permissions on paths specified below overrides previous definition, # everything under those paths is potentially exposed. @@ -123,6 +128,5 @@ Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music XSendFilePath ${MUSIC_DIRECTORY_PATH} SetEnv MOD_X_SENDFILE_ENABLED 1 - diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample index 42659a0da..64ff3d4df 100644 --- a/deploy/env.prod.sample +++ b/deploy/env.prod.sample @@ -113,4 +113,4 @@ RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f # You can safely leave those settings uncommented if you don't plan to use # in place imports. # MUSIC_DIRECTORY_PATH= -# MUSIC_DIRECTORY_SERVE_PATH= +# MUSIC_DIRECTORY_SERVE_PATH= # docker-only diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 66851321f..b403f4388 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -79,18 +79,11 @@ server { alias /srv/funkwhale/data/media/; } - location /_protected/media { - # this is an internal location that is used to serve - # audio files once correct permission / authentication - # has been checked on API side - internal; - alias /srv/funkwhale/data/media; - } - location /_protected/music { # this is an internal location that is used to serve # audio files once correct permission / authentication # has been checked on API side + # Set this to the same value as your MUSIC_DIRECTORY_PATH setting internal; alias /srv/funkwhale/data/music; } diff --git a/docs/configuration.rst b/docs/configuration.rst index 46756bb26..7b7751fc6 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -97,6 +97,12 @@ for this value. For non-docker installation, you can use any absolute path. .. note:: This path should not include any trailing slash +.. warning:: + + You need to adapt your :ref:`reverse-proxy configuration` to + serve the directory pointed by ``MUSIC_DIRECTORY_PATH`` on + ``/_protected/music`` URL. + .. _setting-MUSIC_DIRECTORY_SERVE_PATH: ``MUSIC_DIRECTORY_SERVE_PATH`` @@ -112,7 +118,7 @@ in your :file:`docker-compose.yml` file:: - /srv/funkwhale/data/music:/music:ro Then, the value of :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH` should be -``/srv/funkwhale/data``. This must be readable by the webserver. +``/srv/funkwhale/data/music``. This must be readable by the webserver. On non-docker setup, you don't need to configure this setting. diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index eb0c3f0ea..be17ab1e0 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -3,8 +3,7 @@ Debian installation .. note:: - this guide targets Debian 9, which is the latest debian, but should work - similarly on Debian 8. + This guide targets Debian 9 (Stretch), which is the latest Debian. External dependencies --------------------- @@ -23,7 +22,7 @@ default on system. You can install them using: .. code-block:: shell sudo apt-get update - sudo apt-get install curl python3-venv git unzip + sudo apt-get install curl python3-pip python3-venv git unzip Layout @@ -90,7 +89,7 @@ First, we'll download the latest api release. curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/|version|/download?job=build_api" unzip "api-|version|.zip" -d extracted mv extracted/api/* api/ - rmdir extracted + rm -rf extracted Then we'll download the frontend files: @@ -240,6 +239,14 @@ This will create the required tables and rows. You can safely execute this command any time you want, this will only run unapplied migrations. +.. warning:: + + You may sometimes get the following warning while applying migrations:: + + "Your models have changes that are not yet reflected in a migration, and so won't be applied." + + This is a warning, not an error, and it can be safely ignored. + Never run the ``makemigrations`` command yourself. Create an admin account ----------------------- diff --git a/docs/installation/docker.rst b/docs/installation/docker.rst index dc031caed..e05201800 100644 --- a/docs/installation/docker.rst +++ b/docs/installation/docker.rst @@ -36,6 +36,15 @@ Run the database container and the initial migrations: docker-compose up -d postgres docker-compose run --rm api python manage.py migrate +.. warning:: + + You may sometimes get the following warning while applying migrations:: + + "Your models have changes that are not yet reflected in a migration, and so won't be applied." + + This is a warning, not an error, and it can be safely ignored. + Never run the ``makemigrations`` command yourself. + Create your admin user: .. code-block:: bash diff --git a/docs/installation/external_dependencies.rst b/docs/installation/external_dependencies.rst index 39d32b38f..adfb90e72 100644 --- a/docs/installation/external_dependencies.rst +++ b/docs/installation/external_dependencies.rst @@ -32,10 +32,8 @@ Create the project database and user: .. code-block:: shell - CREATE DATABASE "scratch" - WITH ENCODING 'utf8' - LC_COLLATE = 'en_US.utf8' - LC_CTYPE = 'en_US.utf8'; + CREATE DATABASE "funkwhale" + WITH ENCODING 'utf8'; CREATE USER funkwhale; GRANT ALL PRIVILEGES ON DATABASE funkwhale TO funkwhale; @@ -58,7 +56,7 @@ for funkwhale to work properly: .. code-block:: shell - sudo -u postgres psql -c 'CREATE EXTENSION "unaccent";' + sudo -u postgres psql funkwhale -c 'CREATE EXTENSION "unaccent";' Cache setup (Redis) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 0628fe17f..034f8e9ba 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -124,15 +124,6 @@ If everything is fine, you can restart your nginx server with ``service nginx re Apache2 ^^^^^^^ -.. note:: - - Apache2 support is still very recent and the following features - are not working yet: - - - Websocket (used for real-time updates on Instance timeline) - - Those features are not necessary to use your Funkwhale instance. - Ensure you have a recent version of apache2 installed on your server. You'll also need the following dependencies:: diff --git a/docs/upgrading.rst b/docs/upgrading.rst index bd3d5578f..1b092d747 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -37,6 +37,14 @@ easy: # Relaunch the containers docker-compose up -d +.. warning:: + + You may sometimes get the following warning while applying migrations:: + + "Your models have changes that are not yet reflected in a migration, and so won't be applied." + + This is a warning, not an error, and it can be safely ignored. + Never run the ``makemigrations`` command yourself. Non-docker setup @@ -95,3 +103,12 @@ match what is described in :doc:`debian`: # restart the services sudo systemctl restart funkwhale.target + +.. warning:: + + You may sometimes get the following warning while applying migrations:: + + "Your models have changes that are not yet reflected in a migration, and so won't be applied." + + This is a warning, not an error, and it can be safely ignored. + Never run the ``makemigrations`` command yourself. diff --git a/front/src/App.vue b/front/src/App.vue index 673f83864..2eb673ab4 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,6 +1,7 @@