import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns from datetime import datetime, timedelta import logging logger = logging.getLogger(__name__) class JiraVisualizer: """ Клас для створення візуалізацій даних Jira """ def __init__(self, df): """ Ініціалізація візуалізатора. Args: df (pandas.DataFrame): DataFrame з даними Jira """ self.df = df self._setup_plot_style() def _setup_plot_style(self): """ Налаштування стилю візуалізацій. """ plt.style.use('ggplot') sns.set(style="whitegrid") # Налаштування для українських символів plt.rcParams['font.family'] = 'DejaVu Sans' def plot_status_counts(self): """ Створення діаграми розподілу тікетів за статусами. Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if 'Status' not in self.df.columns: logger.warning("Колонка 'Status' відсутня") return None status_counts = self.df['Status'].value_counts() fig, ax = plt.subplots(figsize=(10, 6)) # Спроба впорядкувати статуси логічно try: status_order = ['To Do', 'In Progress', 'In Review', 'Done', 'Closed'] available_statuses = [s for s in status_order if s in status_counts.index] other_statuses = [s for s in status_counts.index if s not in status_order] ordered_statuses = available_statuses + other_statuses status_counts = status_counts.reindex(ordered_statuses) except Exception as ex: logger.warning(f"Не вдалося впорядкувати статуси: {ex}") sns.barplot(x=status_counts.index, y=status_counts.values, ax=ax) for i, v in enumerate(status_counts.values): ax.text(i, v + 0.5, str(v), ha='center') ax.set_title('Розподіл тікетів за статусами') ax.set_xlabel('Статус') ax.set_ylabel('Кількість') plt.xticks(rotation=45) plt.tight_layout() logger.info("Діаграма статусів успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні діаграми статусів: {e}") return None def plot_priority_counts(self): """ Створення діаграми розподілу тікетів за пріоритетами. Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if 'Priority' not in self.df.columns: logger.warning("Колонка 'Priority' відсутня") return None priority_counts = self.df['Priority'].value_counts() fig, ax = plt.subplots(figsize=(10, 6)) # Спроба впорядкувати пріоритети логічно try: priority_order = ['Highest', 'High', 'Medium', 'Low', 'Lowest'] available_priorities = [p for p in priority_order if p in priority_counts.index] other_priorities = [p for p in priority_counts.index if p not in priority_order] ordered_priorities = available_priorities + other_priorities priority_counts = priority_counts.reindex(ordered_priorities) except Exception as ex: logger.warning(f"Не вдалося впорядкувати пріоритети: {ex}") colors = ['#FF5555', '#FF9C5A', '#FFCC5A', '#5AFF96', '#5AC8FF'] if len(priority_counts) <= len(colors): sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax, palette=colors[:len(priority_counts)]) else: sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax) for i, v in enumerate(priority_counts.values): ax.text(i, v + 0.5, str(v), ha='center') ax.set_title('Розподіл тікетів за пріоритетами') ax.set_xlabel('Пріоритет') ax.set_ylabel('Кількість') plt.xticks(rotation=45) plt.tight_layout() logger.info("Діаграма пріоритетів успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні діаграми пріоритетів: {e}") return None def plot_type_counts(self): """ Створення діаграми розподілу тікетів за типами. Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if 'Issue Type' not in self.df.columns: logger.warning("Колонка 'Issue Type' відсутня") return None type_counts = self.df['Issue Type'].value_counts() fig, ax = plt.subplots(figsize=(10, 6)) sns.barplot(x=type_counts.index, y=type_counts.values, ax=ax) for i, v in enumerate(type_counts.values): ax.text(i, v + 0.5, str(v), ha='center') ax.set_title('Розподіл тікетів за типами') ax.set_xlabel('Тип') ax.set_ylabel('Кількість') plt.xticks(rotation=45) plt.tight_layout() logger.info("Діаграма типів успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні діаграми типів: {e}") return None def plot_created_timeline(self): """ Створення часової діаграми створення тікетів. Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']): logger.warning("Колонка 'Created' відсутня або не містить дат") return None if 'Created_Date' not in self.df.columns: self.df['Created_Date'] = self.df['Created'].dt.date created_by_date = self.df['Created_Date'].value_counts().sort_index() fig, ax = plt.subplots(figsize=(12, 6)) created_by_date.plot(kind='line', marker='o', ax=ax) ax.set_title('Кількість створених тікетів за датами') ax.set_xlabel('Дата') ax.set_ylabel('Кількість') ax.grid(True) plt.tight_layout() logger.info("Часова діаграма успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні часової діаграми: {e}") return None def plot_inactive_issues(self, days=14): """ Створення діаграми неактивних тікетів. Args: days (int): Кількість днів неактивності Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']): logger.warning("Колонка 'Updated' відсутня або не містить дат") return None cutoff_date = datetime.now() - timedelta(days=days) inactive_issues = self.df[self.df['Updated'] < cutoff_date] if len(inactive_issues) == 0: logger.warning("Немає неактивних тікетів для візуалізації") return None if 'Status' in inactive_issues.columns: inactive_by_status = inactive_issues['Status'].value_counts() fig, ax = plt.subplots(figsize=(10, 6)) sns.barplot(x=inactive_by_status.index, y=inactive_by_status.values, ax=ax) for i, v in enumerate(inactive_by_status.values): ax.text(i, v + 0.5, str(v), ha='center') ax.set_title(f'Розподіл неактивних тікетів за статусами (>{days} днів)') ax.set_xlabel('Статус') ax.set_ylabel('Кількість') plt.xticks(rotation=45) plt.tight_layout() logger.info("Діаграма неактивних тікетів успішно створена") return fig else: logger.warning("Колонка 'Status' відсутня для неактивних тікетів") return None except Exception as e: logger.error(f"Помилка при створенні діаграми неактивних тікетів: {e}") return None def plot_status_timeline(self, timeline_df=None): """ Створення діаграми зміни статусів з часом. Args: timeline_df (pandas.DataFrame): DataFrame з часовими даними або None для автоматичного генерування Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if timeline_df is None: if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']): logger.warning("Колонка 'Created' відсутня або не містить дат") return None if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']): logger.warning("Колонка 'Updated' відсутня або не містить дат") return None min_date = self.df['Created'].min().date() max_date = self.df['Updated'].max().date() date_range = pd.date_range(start=min_date, end=max_date, freq='D') timeline_data = [] for date in date_range: date_str = date.strftime('%Y-%m-%d') created_until = self.df[self.df['Created'].dt.date <= date.date()] status_counts = {} for _, row in created_until.iterrows(): if row['Updated'].date() >= date.date(): status = row.get('Status', 'Unknown') status_counts[status] = status_counts.get(status, 0) + 1 timeline_data.append({ 'Date': date_str, 'Total': len(created_until), **status_counts }) timeline_df = pd.DataFrame(timeline_data) timeline_df['Date'] = pd.to_datetime(timeline_df['Date']) else: if not pd.api.types.is_datetime64_dtype(timeline_df['Date']): timeline_df['Date'] = pd.to_datetime(timeline_df['Date']) status_columns = [col for col in timeline_df.columns if col not in ['Date', 'Total']] if not status_columns: logger.warning("Немає даних про статуси для візуалізації") return None fig, ax = plt.subplots(figsize=(14, 8)) status_data = timeline_df[['Date'] + status_columns].set_index('Date') status_data.plot.area(ax=ax, stacked=True, alpha=0.7) ax.set_title('Зміна статусів тікетів з часом') ax.set_xlabel('Дата') ax.set_ylabel('Кількість тікетів') ax.grid(True) plt.tight_layout() logger.info("Часова діаграма статусів успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні часової діаграми статусів: {e}") return None def plot_lead_time_by_type(self): """ Створення діаграми часу виконання за типами тікетів. Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']): logger.warning("Колонка 'Created' відсутня або не містить дат") return None if 'Resolved' not in self.df.columns: logger.warning("Колонка 'Resolved' відсутня") return None if 'Issue Type' not in self.df.columns: logger.warning("Колонка 'Issue Type' відсутня") return None if not pd.api.types.is_datetime64_dtype(self.df['Resolved']): self.df['Resolved'] = pd.to_datetime(self.df['Resolved'], errors='coerce') completed_issues = self.df.dropna(subset=['Resolved']) if len(completed_issues) == 0: logger.warning("Немає завершених тікетів для аналізу") return None completed_issues['Lead_Time_Days'] = (completed_issues['Resolved'] - completed_issues['Created']).dt.days valid_lead_time = completed_issues[completed_issues['Lead_Time_Days'] >= 0] if len(valid_lead_time) == 0: logger.warning("Немає валідних даних про час виконання") return None lead_time_by_type = valid_lead_time.groupby('Issue Type')['Lead_Time_Days'].mean() fig, ax = plt.subplots(figsize=(10, 6)) sns.barplot(x=lead_time_by_type.index, y=lead_time_by_type.values, ax=ax) for i, v in enumerate(lead_time_by_type.values): ax.text(i, v + 0.5, f"{v:.1f}", ha='center') ax.set_title('Середній час виконання тікетів за типами (дні)') ax.set_xlabel('Тип') ax.set_ylabel('Дні') plt.xticks(rotation=45) plt.tight_layout() logger.info("Діаграма часу виконання успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні діаграми часу виконання: {e}") return None # Нові методи, додані до класу JiraVisualizer def plot_assignee_counts(self, limit=10): """ Створення діаграми розподілу тікетів за призначеними користувачами. Args: limit (int): Обмеження на кількість користувачів для відображення Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if 'Assignee' not in self.df.columns: logger.warning("Колонка 'Assignee' відсутня") return None assignee_counts = self.df['Assignee'].value_counts().head(limit) fig, ax = plt.subplots(figsize=(14, 6)) sns.barplot(x=assignee_counts.index, y=assignee_counts.values, ax=ax) for i, v in enumerate(assignee_counts.values): ax.text(i, v + 0.5, str(v), ha='center') ax.set_title(f'Кількість тікетів за призначеними користувачами (Топ {limit})') ax.set_xlabel('Призначений користувач') ax.set_ylabel('Кількість') plt.xticks(rotation=45) plt.tight_layout() logger.info("Діаграма призначених користувачів успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні діаграми призначених користувачів: {e}") return None def plot_timeline(self, date_column='Created', groupby='day', cumulative=False): """ Створення часової діаграми тікетів. Args: date_column (str): Колонка з датою ('Created' або 'Updated') groupby (str): Рівень групування ('day', 'week', 'month') cumulative (bool): Чи показувати кумулятивну суму Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if date_column not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df[date_column]): logger.warning(f"Колонка '{date_column}' відсутня або не містить дати") return None date_col = f"{date_column}_Date" if f"{date_column}_Date" in self.df.columns else date_column if f"{date_column}_Date" not in self.df.columns: self.df[f"{date_column}_Date"] = self.df[date_column].dt.date date_col = f"{date_column}_Date" if groupby == 'week': grouped = self.df[date_column].dt.to_period('W').value_counts().sort_index() title_period = 'тижнями' elif groupby == 'month': grouped = self.df[date_column].dt.to_period('M').value_counts().sort_index() title_period = 'місяцями' else: grouped = self.df[date_col].value_counts().sort_index() title_period = 'датами' if cumulative: grouped = grouped.cumsum() title_prefix = 'Загальна кількість' else: title_prefix = 'Кількість' fig, ax = plt.subplots(figsize=(14, 6)) grouped.plot(kind='line', marker='o', ax=ax) ax.set_title(f'{title_prefix} {date_column.lower()}них тікетів за {title_period}') ax.set_xlabel('Період') ax.set_ylabel('Кількість') ax.grid(True) plt.tight_layout() logger.info(f"Часова діаграма для {date_column} успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні часової діаграми: {e}") return None def plot_heatmap(self, row_col='Issue Type', column_col='Status'): """ Створення теплової карти для візуалізації взаємозв'язку між двома категоріями. Args: row_col (str): Назва колонки для рядків (наприклад, 'Issue Type') column_col (str): Назва колонки для стовпців (наприклад, 'Status') Returns: matplotlib.figure.Figure: Об'єкт figure або None у випадку помилки """ try: if row_col not in self.df.columns or column_col not in self.df.columns: logger.warning(f"Колонки '{row_col}' або '{column_col}' відсутні в даних") return None pivot_table = pd.crosstab(self.df[row_col], self.df[column_col]) fig, ax = plt.subplots(figsize=(14, 8)) sns.heatmap(pivot_table, annot=True, fmt='d', cmap='YlGnBu', ax=ax) ax.set_title(f'Розподіл тікетів: {row_col} за {column_col}') plt.tight_layout() logger.info(f"Теплова карта для {row_col} за {column_col} успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні теплової карти: {e}") return None def plot_project_timeline(self): """ Створення часової шкали проекту, що показує зміну статусів з часом. Returns: tuple: (fig1, fig2) - об'єкти figure для різних візуалізацій або (None, None) у випадку помилки """ try: if 'Created' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Created']): logger.warning("Колонка 'Created' відсутня або не містить дати") return None, None if 'Updated' not in self.df.columns or not pd.api.types.is_datetime64_dtype(self.df['Updated']): logger.warning("Колонка 'Updated' відсутня або не містить дати") return None, None if 'Status' not in self.df.columns: logger.warning("Колонка 'Status' відсутня") return None, None min_date = self.df['Created'].min().date() max_date = self.df['Updated'].max().date() date_range = pd.date_range(start=min_date, end=max_date, freq='D') timeline_data = [] for date in date_range: date_str = date.strftime('%Y-%m-%d') created_until = self.df[self.df['Created'].dt.date <= date.date()] status_counts = {} for _, row in created_until.iterrows(): if row['Updated'].date() >= date.date(): status = row.get('Status', 'Unknown') status_counts[status] = status_counts.get(status, 0) + 1 timeline_data.append({ 'Date': date_str, 'Total': len(created_until), **status_counts }) timeline_df = pd.DataFrame(timeline_data) timeline_df['Date'] = pd.to_datetime(timeline_df['Date']) fig1, ax1 = plt.subplots(figsize=(16, 8)) ax1.plot(timeline_df['Date'], timeline_df['Total'], marker='o', linewidth=2, label='Загальна кількість') status_columns = [col for col in timeline_df.columns if col not in ['Date', 'Total']] for status in status_columns: ax1.plot(timeline_df['Date'], timeline_df[status], marker='.', linestyle='--', label=status) ax1.set_title('Зміна стану проекту з часом') ax1.set_xlabel('Дата') ax1.set_ylabel('Кількість тікетів') plt.xticks(rotation=45) ax1.grid(True) ax1.legend() plt.tight_layout() fig2, ax2 = plt.subplots(figsize=(16, 8)) status_data = timeline_df[['Date'] + status_columns].set_index('Date') status_data.plot.area(ax=ax2, stacked=True, alpha=0.7) ax2.set_title('Склад тікетів за статусами') ax2.set_xlabel('Дата') ax2.set_ylabel('Кількість тікетів') ax2.grid(True) plt.tight_layout() logger.info("Часова шкала проекту успішно створена") return fig1, fig2 except Exception as e: logger.error(f"Помилка при створенні часової шкали проекту: {e}") return None, None def generate_infographic(self): """ Генерація комплексної інфографіки з ключовими показниками Returns: matplotlib.figure.Figure: Об'єкт figure з інфографікою """ try: fig = plt.figure(figsize=(20, 15)) fig.suptitle('Зведений аналіз проекту в Jira', fontsize=24) ax1 = fig.add_subplot(2, 2, 1) if 'Status' in self.df.columns: status_counts = self.df['Status'].value_counts() sns.barplot(x=status_counts.index, y=status_counts.values, ax=ax1) ax1.set_title('Розподіл за статусами') ax1.set_xlabel('Статус') ax1.set_ylabel('Кількість') ax1.tick_params(axis='x', rotation=45) ax2 = fig.add_subplot(2, 2, 2) if 'Priority' in self.df.columns: priority_counts = self.df['Priority'].value_counts() try: priority_order = ['Highest', 'High', 'Medium', 'Low', 'Lowest'] priority_counts = priority_counts.reindex(priority_order, fill_value=0) except Exception as ex: logger.warning(f"Не вдалося впорядкувати пріоритети: {ex}") colors = ['#FF5555', '#FF9C5A', '#FFCC5A', '#5AFF96', '#5AC8FF'] sns.barplot(x=priority_counts.index, y=priority_counts.values, ax=ax2, palette=colors[:len(priority_counts)]) ax2.set_title('Розподіл за пріоритетами') ax2.set_xlabel('Пріоритет') ax2.set_ylabel('Кількість') ax2.tick_params(axis='x', rotation=45) ax3 = fig.add_subplot(2, 2, 3) if 'Created' in self.df.columns and pd.api.types.is_datetime64_dtype(self.df['Created']): created_dates = self.df['Created'].dt.date.value_counts().sort_index() created_cumulative = created_dates.cumsum() created_cumulative.plot(ax=ax3, marker='o') ax3.set_title('Кумулятивне створення тікетів') ax3.set_xlabel('Дата') ax3.set_ylabel('Кількість') ax3.grid(True) ax4 = fig.add_subplot(2, 2, 4) if 'Status' in self.df.columns and 'Issue Type' in self.df.columns: pivot_table = pd.crosstab(self.df['Issue Type'], self.df['Status']) sns.heatmap(pivot_table, annot=True, fmt='d', cmap='YlGnBu', ax=ax4) ax4.set_title('Розподіл: Типи за Статусами') ax4.tick_params(axis='x', rotation=45) plt.tight_layout(rect=[0, 0, 1, 0.96]) logger.info("Інфографіка успішно створена") return fig except Exception as e: logger.error(f"Помилка при створенні інфографіки: {e}") return None def plot_all(self, output_dir=None): """ Створення та збереження всіх діаграм. Args: output_dir (str): Директорія для збереження діаграм. Якщо None, діаграми не зберігаються. Returns: dict: Словник з об'єктами figure для всіх діаграм """ plots = {} plots['status'] = self.plot_status_counts() plots['priority'] = self.plot_priority_counts() plots['type'] = self.plot_type_counts() plots['assignee'] = self.plot_assignee_counts(limit=10) plots['created_timeline'] = self.plot_timeline(date_column='Created', groupby='day') plots['updated_timeline'] = self.plot_timeline(date_column='Updated', groupby='day') plots['created_cumulative'] = self.plot_timeline(date_column='Created', cumulative=True) plots['inactive'] = self.plot_inactive_issues() plots['heatmap_type_status'] = self.plot_heatmap(row_col='Issue Type', column_col='Status') timeline_plots = self.plot_project_timeline() if timeline_plots[0] is not None: plots['project_timeline'] = timeline_plots[0] plots['project_composition'] = timeline_plots[1] if output_dir: from pathlib import Path output_path = Path(output_dir) output_path.mkdir(exist_ok=True, parents=True) for name, fig in plots.items(): if fig: fig_path = output_path / f"{name}.png" fig.savefig(fig_path, dpi=300) logger.info(f"Діаграма {name} збережена у {fig_path}") return plots