Permalink
Join GitHub today
GitHub is home to over 28 million developers working together to host and review code, manage projects, and build software together.
Sign up
Fetching contributors…
Cannot retrieve contributors at this time.
Cannot retrieve contributors at this time
| # -*- coding: utf-8 -*- | |
| """Project models.""" | |
| from __future__ import ( | |
| absolute_import, division, print_function, unicode_literals) | |
| import fnmatch | |
| import logging | |
| import os | |
| from builtins import object # pylint: disable=redefined-builtin | |
| from django.conf import settings | |
| from django.contrib.auth.models import User | |
| from django.core.urlresolvers import NoReverseMatch, reverse | |
| from django.db import models | |
| from django.utils import timezone | |
| from django.utils.encoding import python_2_unicode_compatible | |
| from django.utils.functional import cached_property | |
| from django.utils.translation import ugettext_lazy as _ | |
| from future.backports.urllib.parse import urlparse # noqa | |
| from guardian.shortcuts import assign | |
| from taggit.managers import TaggableManager | |
| from readthedocs.builds.constants import LATEST, LATEST_VERBOSE_NAME, STABLE | |
| from readthedocs.core.resolver import resolve, resolve_domain | |
| from readthedocs.core.utils import broadcast, slugify | |
| from readthedocs.projects import constants | |
| from readthedocs.projects.exceptions import ProjectConfigurationError | |
| from readthedocs.projects.managers import HTMLFileManager | |
| from readthedocs.projects.querysets import ( | |
| ChildRelatedProjectQuerySet, FeatureQuerySet, ProjectQuerySet, | |
| RelatedProjectQuerySet) | |
| from readthedocs.projects.templatetags.projects_tags import sort_version_aware | |
| from readthedocs.projects.validators import validate_domain_name, validate_repository_url | |
| from readthedocs.projects.version_handling import ( | |
| determine_stable_version, version_windows) | |
| from readthedocs.restapi.client import api | |
| from readthedocs.search.parse_json import process_file | |
| from readthedocs.vcs_support.backends import backend_cls | |
| from readthedocs.vcs_support.utils import Lock, NonBlockingLock | |
| log = logging.getLogger(__name__) | |
| @python_2_unicode_compatible | |
| class ProjectRelationship(models.Model): | |
| """ | |
| Project to project relationship. | |
| This is used for subprojects | |
| """ | |
| parent = models.ForeignKey('Project', verbose_name=_('Parent'), | |
| related_name='subprojects') | |
| child = models.ForeignKey('Project', verbose_name=_('Child'), | |
| related_name='superprojects') | |
| alias = models.SlugField(_('Alias'), max_length=255, null=True, blank=True, db_index=False) | |
| objects = ChildRelatedProjectQuerySet.as_manager() | |
| def __str__(self): | |
| return '%s -> %s' % (self.parent, self.child) | |
| def save(self, *args, **kwargs): # pylint: disable=arguments-differ | |
| if not self.alias: | |
| self.alias = self.child.slug | |
| super(ProjectRelationship, self).save(*args, **kwargs) | |
| # HACK | |
| def get_absolute_url(self): | |
| return resolve(self.child) | |
| @python_2_unicode_compatible | |
| class Project(models.Model): | |
| """Project model.""" | |
| # Auto fields | |
| pub_date = models.DateTimeField(_('Publication date'), auto_now_add=True) | |
| modified_date = models.DateTimeField(_('Modified date'), auto_now=True) | |
| # Generally from conf.py | |
| users = models.ManyToManyField(User, verbose_name=_('User'), | |
| related_name='projects') | |
| name = models.CharField(_('Name'), max_length=255) | |
| slug = models.SlugField(_('Slug'), max_length=255, unique=True) | |
| description = models.TextField(_('Description'), blank=True, | |
| help_text=_('The reStructuredText ' | |
| 'description of the project')) | |
| repo = models.CharField(_('Repository URL'), max_length=255, | |
| validators=[validate_repository_url], | |
| help_text=_('Hosted documentation repository URL')) | |
| repo_type = models.CharField(_('Repository type'), max_length=10, | |
| choices=constants.REPO_CHOICES, default='git') | |
| project_url = models.URLField(_('Project homepage'), blank=True, | |
| help_text=_('The project\'s homepage')) | |
| canonical_url = models.URLField(_('Canonical URL'), blank=True, | |
| help_text=_('URL that documentation is expected to serve from')) | |
| version = models.CharField(_('Version'), max_length=100, blank=True, | |
| help_text=_('Project version these docs apply ' | |
| 'to, i.e. 1.0a')) | |
| copyright = models.CharField(_('Copyright'), max_length=255, blank=True, | |
| help_text=_('Project copyright information')) | |
| theme = models.CharField( | |
| _('Theme'), max_length=20, choices=constants.DEFAULT_THEME_CHOICES, | |
| default=constants.THEME_DEFAULT, | |
| help_text=(u'<a href="http://sphinx.pocoo.org/theming.html#builtin-' | |
| 'themes" target="_blank">%s</a>') % _('Examples')) | |
| suffix = models.CharField(_('Suffix'), max_length=10, editable=False, | |
| default='.rst') | |
| single_version = models.BooleanField( | |
| _('Single version'), default=False, | |
| help_text=_('A single version site has no translations and only your ' | |
| '"latest" version, served at the root of the domain. Use ' | |
| 'this with caution, only turn it on if you will <b>never</b> ' | |
| 'have multiple versions of your docs.')) | |
| default_version = models.CharField( | |
| _('Default version'), max_length=255, default=LATEST, | |
| help_text=_('The version of your project that / redirects to')) | |
| # In default_branch, None means the backend should choose the | |
| # appropriate branch. Eg 'master' for git | |
| default_branch = models.CharField( | |
| _('Default branch'), max_length=255, default=None, null=True, | |
| blank=True, help_text=_('What branch "latest" points to. Leave empty ' | |
| 'to use the default value for your VCS (eg. ' | |
| '<code>trunk</code> or <code>master</code>).')) | |
| requirements_file = models.CharField( | |
| _('Requirements file'), max_length=255, default=None, null=True, | |
| blank=True, help_text=_( | |
| 'A <a ' | |
| 'href="https://pip.pypa.io/en/latest/user_guide.html#requirements-files">' | |
| 'pip requirements file</a> needed to build your documentation. ' | |
| 'Path from the root of your project.')) | |
| documentation_type = models.CharField( | |
| _('Documentation type'), max_length=20, | |
| choices=constants.DOCUMENTATION_CHOICES, default='sphinx', | |
| help_text=_('Type of documentation you are building. <a href="' | |
| 'http://www.sphinx-doc.org/en/stable/builders.html#sphinx.builders.html.' | |
| 'DirectoryHTMLBuilder">More info</a>.')) | |
| # Project features | |
| # TODO: remove this? | |
| allow_comments = models.BooleanField(_('Allow Comments'), default=False) | |
| comment_moderation = models.BooleanField( | |
| _('Comment Moderation'), default=False,) | |
| cdn_enabled = models.BooleanField(_('CDN Enabled'), default=False) | |
| analytics_code = models.CharField( | |
| _('Analytics code'), max_length=50, null=True, blank=True, | |
| help_text=_('Google Analytics Tracking ID ' | |
| '(ex. <code>UA-22345342-1</code>). ' | |
| 'This may slow down your page loads.')) | |
| container_image = models.CharField( | |
| _('Alternative container image'), max_length=64, null=True, blank=True) | |
| container_mem_limit = models.CharField( | |
| _('Container memory limit'), max_length=10, null=True, blank=True, | |
| help_text=_('Memory limit in Docker format ' | |
| '-- example: <code>512m</code> or <code>1g</code>')) | |
| container_time_limit = models.CharField( | |
| _('Container time limit'), max_length=10, null=True, blank=True) | |
| build_queue = models.CharField( | |
| _('Alternate build queue id'), max_length=32, null=True, blank=True) | |
| allow_promos = models.BooleanField( | |
| _('Allow paid advertising'), default=True, help_text=_( | |
| 'If unchecked, users will still see community ads.')) | |
| ad_free = models.BooleanField( | |
| _('Ad-free'), | |
| default=False, | |
| help_text='If checked, do not show advertising for this project', | |
| ) | |
| show_version_warning = models.BooleanField( | |
| _('Show version warning'), default=False, | |
| help_text=_('Show warning banner in non-stable nor latest versions.') | |
| ) | |
| # Sphinx specific build options. | |
| enable_epub_build = models.BooleanField( | |
| _('Enable EPUB build'), default=True, | |
| help_text=_( | |
| 'Create a EPUB version of your documentation with each build.')) | |
| enable_pdf_build = models.BooleanField( | |
| _('Enable PDF build'), default=True, | |
| help_text=_( | |
| 'Create a PDF version of your documentation with each build.')) | |
| # Other model data. | |
| path = models.CharField(_('Path'), max_length=255, editable=False, | |
| help_text=_('The directory where ' | |
| '<code>conf.py</code> lives')) | |
| conf_py_file = models.CharField( | |
| _('Python configuration file'), max_length=255, default='', blank=True, | |
| help_text=_('Path from project root to <code>conf.py</code> file ' | |
| '(ex. <code>docs/conf.py</code>). ' | |
| 'Leave blank if you want us to find it for you.')) | |
| featured = models.BooleanField(_('Featured'), default=False) | |
| skip = models.BooleanField(_('Skip'), default=False) | |
| mirror = models.BooleanField(_('Mirror'), default=False) | |
| install_project = models.BooleanField( | |
| _('Install Project'), | |
| help_text=_('Install your project inside a virtualenv using <code>setup.py ' | |
| 'install</code>'), | |
| default=False | |
| ) | |
| # This model attribute holds the python interpreter used to create the | |
| # virtual environment | |
| python_interpreter = models.CharField( | |
| _('Python Interpreter'), | |
| max_length=20, | |
| choices=constants.PYTHON_CHOICES, | |
| default='python', | |
| help_text=_('The Python interpreter used to create the virtual ' | |
| 'environment.')) | |
| use_system_packages = models.BooleanField( | |
| _('Use system packages'), | |
| help_text=_('Give the virtual environment access to the global ' | |
| 'site-packages dir.'), | |
| default=False | |
| ) | |
| django_packages_url = models.CharField(_('Django Packages URL'), | |
| max_length=255, blank=True) | |
| privacy_level = models.CharField( | |
| _('Privacy Level'), max_length=20, choices=constants.PRIVACY_CHOICES, | |
| default=getattr(settings, 'DEFAULT_PRIVACY_LEVEL', 'public'), | |
| help_text=_('Level of privacy that you want on the repository. ' | |
| 'Protected means public but not in listings.')) | |
| version_privacy_level = models.CharField( | |
| _('Version Privacy Level'), max_length=20, | |
| choices=constants.PRIVACY_CHOICES, default=getattr( | |
| settings, 'DEFAULT_PRIVACY_LEVEL', 'public'), | |
| help_text=_('Default level of privacy you want on built ' | |
| 'versions of documentation.')) | |
| # Subprojects | |
| related_projects = models.ManyToManyField( | |
| 'self', verbose_name=_('Related projects'), blank=True, | |
| symmetrical=False, through=ProjectRelationship) | |
| # Language bits | |
| language = models.CharField(_('Language'), max_length=20, default='en', | |
| help_text=_('The language the project ' | |
| 'documentation is rendered in. ' | |
| "Note: this affects your project's URL."), | |
| choices=constants.LANGUAGES) | |
| programming_language = models.CharField( | |
| _('Programming Language'), | |
| max_length=20, | |
| default='words', | |
| help_text=_( | |
| 'The primary programming language the project is written in.'), | |
| choices=constants.PROGRAMMING_LANGUAGES, blank=True) | |
| # A subproject pointed at its main language, so it can be tracked | |
| main_language_project = models.ForeignKey('self', | |
| related_name='translations', | |
| on_delete=models.SET_NULL, | |
| blank=True, null=True) | |
| # Version State | |
| num_major = models.IntegerField( | |
| _('Number of Major versions'), | |
| default=2, | |
| null=True, | |
| blank=True, | |
| help_text=_('2 means supporting 3.X.X and 2.X.X, but not 1.X.X') | |
| ) | |
| num_minor = models.IntegerField( | |
| _('Number of Minor versions'), | |
| default=2, | |
| null=True, | |
| blank=True, | |
| help_text=_('2 means supporting 2.2.X and 2.1.X, but not 2.0.X') | |
| ) | |
| num_point = models.IntegerField( | |
| _('Number of Point versions'), | |
| default=2, | |
| null=True, | |
| blank=True, | |
| help_text=_('2 means supporting 2.2.2 and 2.2.1, but not 2.2.0') | |
| ) | |
| has_valid_webhook = models.BooleanField( | |
| default=False, help_text=_('This project has been built with a webhook') | |
| ) | |
| has_valid_clone = models.BooleanField( | |
| default=False, help_text=_('This project has been successfully cloned') | |
| ) | |
| tags = TaggableManager(blank=True) | |
| objects = ProjectQuerySet.as_manager() | |
| all_objects = models.Manager() | |
| class Meta(object): | |
| ordering = ('slug',) | |
| permissions = ( | |
| # Translators: Permission around whether a user can view the | |
| # project | |
| ('view_project', _('View Project')), | |
| ) | |
| def __str__(self): | |
| return self.name | |
| def sync_supported_versions(self): | |
| supported = self.supported_versions() | |
| if supported: | |
| self.versions.filter( | |
| verbose_name__in=supported).update(supported=True) | |
| self.versions.exclude( | |
| verbose_name__in=supported).update(supported=False) | |
| self.versions.filter( | |
| verbose_name=LATEST_VERBOSE_NAME).update(supported=True) | |
| def save(self, *args, **kwargs): # pylint: disable=arguments-differ | |
| from readthedocs.projects import tasks | |
| first_save = self.pk is None | |
| if not self.slug: | |
| # Subdomains can't have underscores in them. | |
| self.slug = slugify(self.name) | |
| if self.slug == '': | |
| raise Exception(_('Model must have slug')) | |
| if self.documentation_type == 'auto': | |
| # This used to determine the type and automatically set the | |
| # documentation type to Sphinx for rST and Mkdocs for markdown. | |
| # It now just forces Sphinx, due to markdown support. | |
| self.documentation_type = 'sphinx' | |
| super(Project, self).save(*args, **kwargs) | |
| for owner in self.users.all(): | |
| assign('view_project', owner, self) | |
| try: | |
| if self.default_branch: | |
| latest = self.versions.get(slug=LATEST) | |
| if latest.identifier != self.default_branch: | |
| latest.identifier = self.default_branch | |
| latest.save() | |
| except Exception: | |
| log.exception('Failed to update latest identifier') | |
| # Add exceptions here for safety | |
| try: | |
| self.sync_supported_versions() | |
| except Exception: | |
| log.exception('failed to sync supported versions') | |
| try: | |
| if not first_save: | |
| log.info( | |
| 'Re-symlinking project and subprojects: project=%s', | |
| self.slug, | |
| ) | |
| broadcast( | |
| type='app', | |
| task=tasks.symlink_project, | |
| args=[self.pk], | |
| ) | |
| log.info( | |
| 'Re-symlinking superprojects: project=%s', | |
| self.slug, | |
| ) | |
| for relationship in self.superprojects.all(): | |
| broadcast( | |
| type='app', | |
| task=tasks.symlink_project, | |
| args=[relationship.parent.pk], | |
| ) | |
| except Exception: | |
| log.exception('failed to symlink project') | |
| try: | |
| if not first_save: | |
| broadcast( | |
| type='app', task=tasks.update_static_metadata, args=[self.pk],) | |
| except Exception: | |
| log.exception('failed to update static metadata') | |
| try: | |
| branch = self.default_branch or self.vcs_repo().fallback_branch | |
| if not self.versions.filter(slug=LATEST).exists(): | |
| self.versions.create_latest(identifier=branch) | |
| except Exception: | |
| log.exception('Error creating default branches') | |
| def get_absolute_url(self): | |
| return reverse('projects_detail', args=[self.slug]) | |
| def get_docs_url(self, version_slug=None, lang_slug=None, private=None): | |
| """ | |
| Return a URL for the docs. | |
| Always use http for now, to avoid content warnings. | |
| """ | |
| return resolve(project=self, version_slug=version_slug, language=lang_slug, private=private) | |
| def get_builds_url(self): | |
| return reverse('builds_project_list', kwargs={ | |
| 'project_slug': self.slug, | |
| }) | |
| def get_canonical_url(self): | |
| if getattr(settings, 'DONT_HIT_DB', True): | |
| return api.project(self.pk).canonical_url().get()['url'] | |
| return self.get_docs_url() | |
| def get_subproject_urls(self): | |
| """ | |
| List subproject URLs. | |
| This is used in search result linking | |
| """ | |
| if getattr(settings, 'DONT_HIT_DB', True): | |
| return [(proj['slug'], proj['canonical_url']) | |
| for proj in ( | |
| api.project(self.pk) | |
| .subprojects() | |
| .get()['subprojects'])] | |
| return [(proj.child.slug, proj.child.get_docs_url()) | |
| for proj in self.subprojects.all()] | |
| def get_production_media_path(self, type_, version_slug, include_file=True): | |
| """ | |
| Used to see if these files exist so we can offer them for download. | |
| :param type_: Media content type, ie - 'pdf', 'zip' | |
| :param version_slug: Project version slug for lookup | |
| :param include_file: Include file name in return | |
| :type include_file: bool | |
| :returns: Full path to media file or path | |
| """ | |
| if getattr(settings, 'DEFAULT_PRIVACY_LEVEL', 'public') == 'public' or settings.DEBUG: | |
| path = os.path.join( | |
| settings.MEDIA_ROOT, type_, self.slug, version_slug) | |
| else: | |
| path = os.path.join( | |
| settings.PRODUCTION_MEDIA_ARTIFACTS, type_, self.slug, version_slug) | |
| if include_file: | |
| path = os.path.join( | |
| path, '%s.%s' % (self.slug, type_.replace('htmlzip', 'zip'))) | |
| return path | |
| def get_production_media_url(self, type_, version_slug, full_path=True): | |
| """Get the URL for downloading a specific media file.""" | |
| try: | |
| path = reverse('project_download_media', kwargs={ | |
| 'project_slug': self.slug, | |
| 'type_': type_, | |
| 'version_slug': version_slug, | |
| }) | |
| except NoReverseMatch: | |
| return '' | |
| if full_path: | |
| path = '//%s%s' % (settings.PRODUCTION_DOMAIN, path) | |
| return path | |
| def subdomain(self): | |
| """Get project subdomain from resolver.""" | |
| return resolve_domain(self) | |
| def get_downloads(self): | |
| downloads = {} | |
| downloads['htmlzip'] = self.get_production_media_url( | |
| 'htmlzip', self.get_default_version()) | |
| downloads['epub'] = self.get_production_media_url( | |
| 'epub', self.get_default_version()) | |
| downloads['pdf'] = self.get_production_media_url( | |
| 'pdf', self.get_default_version()) | |
| return downloads | |
| @property | |
| def clean_repo(self): | |
| if self.repo.startswith('http://github.com'): | |
| return self.repo.replace('http://github.com', 'https://github.com') | |
| return self.repo | |
| # Doc PATH: | |
| # MEDIA_ROOT/slug/checkouts/version/<repo> | |
| @property | |
| def doc_path(self): | |
| return os.path.join(settings.DOCROOT, self.slug.replace('_', '-')) | |
| def checkout_path(self, version=LATEST): | |
| return os.path.join(self.doc_path, 'checkouts', version) | |
| @property | |
| def pip_cache_path(self): | |
| """Path to pip cache.""" | |
| if getattr(settings, 'GLOBAL_PIP_CACHE', False) and settings.DEBUG: | |
| return settings.GLOBAL_PIP_CACHE | |
| return os.path.join(self.doc_path, '.cache', 'pip') | |
| # | |
| # Paths for symlinks in project doc_path. | |
| # | |
| def translations_symlink_path(self, language=None): | |
| """Path in the doc_path that we symlink translations.""" | |
| if not language: | |
| language = self.language | |
| return os.path.join(self.doc_path, 'translations', language) | |
| # | |
| # End symlink paths | |
| # | |
| def full_doc_path(self, version=LATEST): | |
| """The path to the documentation root in the project.""" | |
| doc_base = self.checkout_path(version) | |
| for possible_path in ['docs', 'doc', 'Doc']: | |
| if os.path.exists(os.path.join(doc_base, '%s' % possible_path)): | |
| return os.path.join(doc_base, '%s' % possible_path) | |
| # No docs directory, docs are at top-level. | |
| return doc_base | |
| def artifact_path(self, type_, version=LATEST): | |
| """The path to the build html docs in the project.""" | |
| return os.path.join(self.doc_path, 'artifacts', version, type_) | |
| def full_build_path(self, version=LATEST): | |
| """The path to the build html docs in the project.""" | |
| return os.path.join(self.conf_dir(version), '_build', 'html') | |
| def full_latex_path(self, version=LATEST): | |
| """The path to the build LaTeX docs in the project.""" | |
| return os.path.join(self.conf_dir(version), '_build', 'latex') | |
| def full_epub_path(self, version=LATEST): | |
| """The path to the build epub docs in the project.""" | |
| return os.path.join(self.conf_dir(version), '_build', 'epub') | |
| # There is currently no support for building man/dash formats, but we keep | |
| # the support there for existing projects. They might have already existing | |
| # legacy builds. | |
| def full_man_path(self, version=LATEST): | |
| """The path to the build man docs in the project.""" | |
| return os.path.join(self.conf_dir(version), '_build', 'man') | |
| def full_dash_path(self, version=LATEST): | |
| """The path to the build dash docs in the project.""" | |
| return os.path.join(self.conf_dir(version), '_build', 'dash') | |
| def full_json_path(self, version=LATEST): | |
| """The path to the build json docs in the project.""" | |
| if 'sphinx' in self.documentation_type: | |
| return os.path.join(self.conf_dir(version), '_build', 'json') | |
| if 'mkdocs' in self.documentation_type: | |
| return os.path.join(self.checkout_path(version), '_build', 'json') | |
| def full_singlehtml_path(self, version=LATEST): | |
| """The path to the build singlehtml docs in the project.""" | |
| return os.path.join(self.conf_dir(version), '_build', 'singlehtml') | |
| def rtd_build_path(self, version=LATEST): | |
| """The destination path where the built docs are copied.""" | |
| return os.path.join(self.doc_path, 'rtd-builds', version) | |
| def static_metadata_path(self): | |
| """The path to the static metadata JSON settings file.""" | |
| return os.path.join(self.doc_path, 'metadata.json') | |
| def conf_file(self, version=LATEST): | |
| """Find a ``conf.py`` file in the project checkout.""" | |
| if self.conf_py_file: | |
| conf_path = os.path.join( | |
| self.checkout_path(version), self.conf_py_file,) | |
| if os.path.exists(conf_path): | |
| log.info('Inserting conf.py file path from model') | |
| return conf_path | |
| log.warning("Conf file specified on model doesn't exist") | |
| files = self.find('conf.py', version) | |
| if not files: | |
| files = self.full_find('conf.py', version) | |
| if len(files) == 1: | |
| return files[0] | |
| for filename in files: | |
| # When multiples conf.py files, we look up the first one that | |
| # contains the `doc` word in its path and return this one | |
| if filename.find('doc', 70) != -1: | |
| return filename | |
| # If the project has more than one conf.py file but none of them have | |
| # the `doc` word in the path, we raise an error informing this to the user | |
| if len(files) > 1: | |
| raise ProjectConfigurationError( | |
| ProjectConfigurationError.MULTIPLE_CONF_FILES | |
| ) | |
| raise ProjectConfigurationError( | |
| ProjectConfigurationError.NOT_FOUND | |
| ) | |
| def conf_dir(self, version=LATEST): | |
| conf_file = self.conf_file(version) | |
| if conf_file: | |
| return os.path.dirname(conf_file) | |
| @property | |
| def is_imported(self): | |
| return bool(self.repo) | |
| @property | |
| def has_good_build(self): | |
| return self.builds.filter(success=True).exists() | |
| @property | |
| def has_versions(self): | |
| return self.versions.exists() | |
| @property | |
| def has_aliases(self): | |
| return self.aliases.exists() | |
| def has_pdf(self, version_slug=LATEST): | |
| if not self.enable_pdf_build: | |
| return False | |
| return os.path.exists(self.get_production_media_path( | |
| type_='pdf', version_slug=version_slug)) | |
| def has_epub(self, version_slug=LATEST): | |
| if not self.enable_epub_build: | |
| return False | |
| return os.path.exists(self.get_production_media_path( | |
| type_='epub', version_slug=version_slug)) | |
| def has_htmlzip(self, version_slug=LATEST): | |
| return os.path.exists(self.get_production_media_path( | |
| type_='htmlzip', version_slug=version_slug)) | |
| @property | |
| def sponsored(self): | |
| return False | |
| def vcs_repo(self, version=LATEST, environment=None): | |
| """ | |
| Return a Backend object for this project able to handle VCS commands. | |
| :param environment: environment to run the commands | |
| :type environment: doc_builder.environments.BuildEnvironment | |
| :param version: version slug for the backend (``LATEST`` by default) | |
| :type version: str | |
| """ | |
| # TODO: this seems to be the only method that receives a | |
| # ``version.slug`` instead of a ``Version`` instance (I prefer an | |
| # instance here) | |
| backend = backend_cls.get(self.repo_type) | |
| if not backend: | |
| repo = None | |
| else: | |
| repo = backend(self, version, environment) | |
| return repo | |
| def repo_nonblockinglock(self, version, max_lock_age=5): | |
| return NonBlockingLock(project=self, version=version, max_lock_age=max_lock_age) | |
| def repo_lock(self, version, timeout=5, polling_interval=5): | |
| return Lock(self, version, timeout, polling_interval) | |
| def find(self, filename, version): | |
| """ | |
| Find files inside the project's ``doc`` path. | |
| :param filename: Filename to search for in project checkout | |
| :param version: Version instance to set version checkout path | |
| """ | |
| matches = [] | |
| for root, __, filenames in os.walk(self.full_doc_path(version)): | |
| for match in fnmatch.filter(filenames, filename): | |
| matches.append(os.path.join(root, match)) | |
| return matches | |
| def full_find(self, filename, version): | |
| """ | |
| Find files inside a project's checkout path. | |
| :param filename: Filename to search for in project checkout | |
| :param version: Version instance to set version checkout path | |
| """ | |
| matches = [] | |
| for root, __, filenames in os.walk(self.checkout_path(version)): | |
| for match in fnmatch.filter(filenames, filename): | |
| matches.append(os.path.join(root, match)) | |
| return matches | |
| def get_latest_build(self, finished=True): | |
| """ | |
| Get latest build for project. | |
| :param finished: Return only builds that are in a finished state | |
| """ | |
| kwargs = {'type': 'html'} | |
| if finished: | |
| kwargs['state'] = 'finished' | |
| return self.builds.filter(**kwargs).first() | |
| def api_versions(self): | |
| from readthedocs.builds.models import APIVersion | |
| ret = [] | |
| for version_data in api.project(self.pk).active_versions.get()['versions']: | |
| version = APIVersion(**version_data) | |
| ret.append(version) | |
| return sort_version_aware(ret) | |
| def active_versions(self): | |
| from readthedocs.builds.models import Version | |
| versions = Version.objects.public(project=self, only_active=True) | |
| return (versions.filter(built=True, active=True) | | |
| versions.filter(active=True, uploaded=True)) | |
| def ordered_active_versions(self, user=None): | |
| from readthedocs.builds.models import Version | |
| kwargs = { | |
| 'project': self, | |
| 'only_active': True, | |
| } | |
| if user: | |
| kwargs['user'] = user | |
| versions = Version.objects.public(**kwargs) | |
| return sort_version_aware(versions) | |
| def all_active_versions(self): | |
| """ | |
| Get queryset with all active versions. | |
| .. note:: | |
| This is a temporary workaround for activate_versions filtering out | |
| things that were active, but failed to build | |
| :returns: :py:class:`Version` queryset | |
| """ | |
| return self.versions.filter(active=True) | |
| def supported_versions(self): | |
| """ | |
| Get the list of supported versions. | |
| :returns: List of version strings. | |
| """ | |
| if not self.num_major or not self.num_minor or not self.num_point: | |
| return [] | |
| version_identifiers = self.versions.values_list( | |
| 'verbose_name', flat=True,) | |
| return version_windows( | |
| version_identifiers, | |
| major=self.num_major, | |
| minor=self.num_minor, | |
| point=self.num_point, | |
| ) | |
| def get_stable_version(self): | |
| return self.versions.filter(slug=STABLE).first() | |
| def update_stable_version(self): | |
| """ | |
| Returns the version that was promoted to be the new stable version. | |
| Return ``None`` if no update was mode or if there is no version on the | |
| project that can be considered stable. | |
| """ | |
| versions = self.versions.all() | |
| new_stable = determine_stable_version(versions) | |
| if new_stable: | |
| current_stable = self.get_stable_version() | |
| if current_stable: | |
| identifier_updated = ( | |
| new_stable.identifier != current_stable.identifier) | |
| if identifier_updated and current_stable.active and current_stable.machine: | |
| log.info( | |
| 'Update stable version: {project}:{version}'.format( | |
| project=self.slug, | |
| version=new_stable.identifier)) | |
| current_stable.identifier = new_stable.identifier | |
| current_stable.save() | |
| return new_stable | |
| else: | |
| log.info( | |
| 'Creating new stable version: {project}:{version}'.format( | |
| project=self.slug, | |
| version=new_stable.identifier)) | |
| current_stable = self.versions.create_stable( | |
| type=new_stable.type, | |
| identifier=new_stable.identifier) | |
| return new_stable | |
| def versions_from_branch_name(self, branch): | |
| return ( | |
| self.versions.filter(identifier=branch) | | |
| self.versions.filter(identifier='remotes/origin/%s' % branch) | | |
| self.versions.filter(identifier='origin/%s' % branch) | | |
| self.versions.filter(verbose_name=branch) | |
| ) | |
| def get_default_version(self): | |
| """ | |
| Get the default version (slug). | |
| Returns self.default_version if the version with that slug actually | |
| exists (is built and published). Otherwise returns 'latest'. | |
| """ | |
| # latest is a special case where we don't have to check if it exists | |
| if self.default_version == LATEST: | |
| return self.default_version | |
| # check if the default_version exists | |
| version_qs = self.versions.filter( | |
| slug=self.default_version, active=True | |
| ) | |
| if version_qs.exists(): | |
| return self.default_version | |
| return LATEST | |
| def get_default_branch(self): | |
| """Get the version representing 'latest'.""" | |
| if self.default_branch: | |
| return self.default_branch | |
| return self.vcs_repo().fallback_branch | |
| def add_subproject(self, child, alias=None): | |
| subproject, __ = ProjectRelationship.objects.get_or_create( | |
| parent=self, child=child, alias=alias, | |
| ) | |
| return subproject | |
| def remove_subproject(self, child): | |
| ProjectRelationship.objects.filter(parent=self, child=child).delete() | |
| @property | |
| def features(self): | |
| return Feature.objects.for_project(self) | |
| def has_feature(self, feature_id): | |
| """ | |
| Does project have existing feature flag. | |
| If the feature has a historical True value before the feature was added, | |
| we consider the project to have the flag. This is used for deprecating a | |
| feature or changing behavior for new projects | |
| """ | |
| return self.features.filter(feature_id=feature_id).exists() | |
| def get_feature_value(self, feature, positive, negative): | |
| """ | |
| Look up project feature, return corresponding value. | |
| If a project has a feature, return ``positive``, otherwise return | |
| ``negative`` | |
| """ | |
| return positive if self.has_feature(feature) else negative | |
| @property | |
| def show_advertising(self): | |
| """ | |
| Whether this project is ad-free | |
| :return: ``True`` if advertising should be shown and ``False`` otherwise | |
| """ | |
| if self.ad_free or self.gold_owners.exists(): | |
| return False | |
| return True | |
| class APIProject(Project): | |
| """ | |
| Project proxy model for API data deserialization. | |
| This replaces the pattern where API data was deserialized into a mocked | |
| :py:class:`Project` object. This pattern was confusing, as it was not explicit | |
| as to what form of object you were working with -- API backed or database | |
| backed. | |
| This model preserves the Project model methods, allowing for overrides on | |
| model field differences. This model pattern will generally only be used on | |
| builder instances, where we are interacting solely with API data. | |
| """ | |
| features = [] | |
| class Meta: | |
| proxy = True | |
| def __init__(self, *args, **kwargs): | |
| self.features = kwargs.pop('features', []) | |
| # These fields only exist on the API return, not on the model, so we'll | |
| # remove them to avoid throwing exceptions due to unexpected fields | |
| for key in ['users', 'resource_uri', 'absolute_url', 'downloads', | |
| 'main_language_project', 'related_projects']: | |
| try: | |
| del kwargs[key] | |
| except KeyError: | |
| pass | |
| super(APIProject, self).__init__(*args, **kwargs) | |
| # Overwrite the database property with the value from the API | |
| self.ad_free = (not kwargs.pop('show_advertising', True)) | |
| def save(self, *args, **kwargs): | |
| return 0 | |
| def has_feature(self, feature_id): | |
| return feature_id in self.features | |
| @property | |
| def show_advertising(self): | |
| """Whether this project is ad-free (don't access the database)""" | |
| return not self.ad_free | |
| @python_2_unicode_compatible | |
| class ImportedFile(models.Model): | |
| """ | |
| Imported files model. | |
| This tracks files that are output from documentation builds, useful for | |
| things like CDN invalidation. | |
| """ | |
| project = models.ForeignKey('Project', verbose_name=_('Project'), | |
| related_name='imported_files') | |
| version = models.ForeignKey('builds.Version', verbose_name=_('Version'), | |
| related_name='imported_files', null=True) | |
| name = models.CharField(_('Name'), max_length=255) | |
| slug = models.SlugField(_('Slug')) | |
| path = models.CharField(_('Path'), max_length=255) | |
| md5 = models.CharField(_('MD5 checksum'), max_length=255) | |
| commit = models.CharField(_('Commit'), max_length=255) | |
| modified_date = models.DateTimeField(_('Modified date'), auto_now=True) | |
| def get_absolute_url(self): | |
| return resolve(project=self.project, version_slug=self.version.slug, filename=self.path) | |
| def __str__(self): | |
| return '%s: %s' % (self.name, self.project) | |
| class HTMLFile(ImportedFile): | |
| """ | |
| Imported HTML file Proxy model. | |
| This tracks only the HTML files for indexing to search. | |
| """ | |
| class Meta(object): | |
| proxy = True | |
| objects = HTMLFileManager() | |
| @cached_property | |
| def json_file_path(self): | |
| basename = os.path.splitext(self.path)[0] | |
| file_path = basename + '.fjson' | |
| full_json_path = self.project.get_production_media_path(type_='json', | |
| version_slug=self.version.slug, | |
| include_file=False) | |
| file_path = os.path.join(full_json_path, file_path) | |
| return file_path | |
| def get_processed_json(self): | |
| file_path = self.json_file_path | |
| try: | |
| return process_file(file_path) | |
| except Exception: | |
| log.warning('Unhandled exception during search processing file: %s' % file_path) | |
| return { | |
| 'headers': [], | |
| 'content': '', | |
| 'path': file_path, | |
| 'title': '', | |
| 'sections': [] | |
| } | |
| @cached_property | |
| def processed_json(self): | |
| return self.get_processed_json() | |
| class Notification(models.Model): | |
| project = models.ForeignKey(Project, | |
| related_name='%(class)s_notifications') | |
| objects = RelatedProjectQuerySet.as_manager() | |
| class Meta(object): | |
| abstract = True | |
| @python_2_unicode_compatible | |
| class EmailHook(Notification): | |
| email = models.EmailField() | |
| def __str__(self): | |
| return self.email | |
| @python_2_unicode_compatible | |
| class WebHook(Notification): | |
| url = models.URLField(blank=True, | |
| help_text=_('URL to send the webhook to')) | |
| def __str__(self): | |
| return self.url | |
| @python_2_unicode_compatible | |
| class Domain(models.Model): | |
| """A custom domain name for a project.""" | |
| project = models.ForeignKey(Project, related_name='domains') | |
| domain = models.CharField(_('Domain'), unique=True, max_length=255, | |
| validators=[validate_domain_name]) | |
| machine = models.BooleanField( | |
| default=False, help_text=_('This Domain was auto-created') | |
| ) | |
| cname = models.BooleanField( | |
| default=False, help_text=_('This Domain is a CNAME for the project') | |
| ) | |
| canonical = models.BooleanField( | |
| default=False, | |
| help_text=_( | |
| 'This Domain is the primary one where the documentation is ' | |
| 'served from') | |
| ) | |
| https = models.BooleanField( | |
| _('Use HTTPS'), | |
| default=False, | |
| help_text=_('Always use HTTPS for this domain') | |
| ) | |
| count = models.IntegerField(default=0, help_text=_( | |
| 'Number of times this domain has been hit'),) | |
| objects = RelatedProjectQuerySet.as_manager() | |
| class Meta(object): | |
| ordering = ('-canonical', '-machine', 'domain') | |
| def __str__(self): | |
| return '{domain} pointed at {project}'.format(domain=self.domain, project=self.project.name) | |
| def save(self, *args, **kwargs): # pylint: disable=arguments-differ | |
| from readthedocs.projects import tasks | |
| parsed = urlparse(self.domain) | |
| if parsed.scheme or parsed.netloc: | |
| self.domain = parsed.netloc | |
| else: | |
| self.domain = parsed.path | |
| super(Domain, self).save(*args, **kwargs) | |
| broadcast(type='app', task=tasks.symlink_domain, | |
| args=[self.project.pk, self.pk],) | |
| def delete(self, *args, **kwargs): # pylint: disable=arguments-differ | |
| from readthedocs.projects import tasks | |
| broadcast(type='app', task=tasks.symlink_domain, | |
| args=[self.project.pk, self.pk, True],) | |
| super(Domain, self).delete(*args, **kwargs) | |
| @python_2_unicode_compatible | |
| class Feature(models.Model): | |
| """ | |
| Project feature flags. | |
| Features should generally be added here as choices, however features may | |
| also be added dynamically from a signal in other packages. Features can be | |
| added by external packages with the use of signals:: | |
| @receiver(pre_init, sender=Feature) | |
| def add_features(sender, **kwargs): | |
| sender.FEATURES += (('blah', 'BLAH'),) | |
| The FeatureForm will grab the updated list on instantiation. | |
| """ | |
| # Feature constants - this is not a exhaustive list of features, features | |
| # may be added by other packages | |
| USE_SPHINX_LATEST = 'use_sphinx_latest' | |
| USE_SETUPTOOLS_LATEST = 'use_setuptools_latest' | |
| ALLOW_DEPRECATED_WEBHOOKS = 'allow_deprecated_webhooks' | |
| PIP_ALWAYS_UPGRADE = 'pip_always_upgrade' | |
| SKIP_SUBMODULES = 'skip_submodules' | |
| DONT_OVERWRITE_SPHINX_CONTEXT = 'dont_overwrite_sphinx_context' | |
| ALLOW_V2_CONFIG_FILE = 'allow_v2_config_file' | |
| FEATURES = ( | |
| (USE_SPHINX_LATEST, _('Use latest version of Sphinx')), | |
| (USE_SETUPTOOLS_LATEST, _('Use latest version of setuptools')), | |
| (ALLOW_DEPRECATED_WEBHOOKS, _('Allow deprecated webhook views')), | |
| (PIP_ALWAYS_UPGRADE, _('Always run pip install --upgrade')), | |
| (SKIP_SUBMODULES, _('Skip git submodule checkout')), | |
| (DONT_OVERWRITE_SPHINX_CONTEXT, _( | |
| 'Do not overwrite context vars in conf.py with Read the Docs context',)), | |
| (ALLOW_V2_CONFIG_FILE, _( | |
| 'Allow to use the v2 of the configuration file')), | |
| ) | |
| projects = models.ManyToManyField( | |
| Project, | |
| blank=True, | |
| ) | |
| # Feature is not implemented as a ChoiceField, as we don't want validation | |
| # at the database level on this field. Arbitrary values are allowed here. | |
| feature_id = models.CharField( | |
| _('Feature identifier'), | |
| max_length=32, | |
| unique=True, | |
| ) | |
| add_date = models.DateTimeField( | |
| _('Date feature was added'), | |
| auto_now_add=True, | |
| ) | |
| default_true = models.BooleanField( | |
| _('Historical default is True'), | |
| default=False, | |
| ) | |
| objects = FeatureQuerySet.as_manager() | |
| def __str__(self): | |
| return '{0} feature'.format( | |
| self.get_feature_display(), | |
| ) | |
| def get_feature_display(self): | |
| """ | |
| Implement display name field for fake ChoiceField. | |
| Because the field is not a ChoiceField here, we need to manually | |
| implement this behavior. | |
| """ | |
| return dict(self.FEATURES).get(self.feature_id, self.feature_id) |