Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.28 on 2026-03-03 08:18

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('workspaces', '0060_workspace_org_settings'),
]

operations = [
migrations.AddField(
model_name='featureconfig',
name='import_billable_tooltip_dismissed',
field=models.BooleanField(default=False, help_text='Import billable tooltip dismissed'),
),
]
3 changes: 3 additions & 0 deletions apps/workspaces/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime, timezone

from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField
from django.core.cache import cache
Expand All @@ -7,6 +8,7 @@
from django_q.models import Schedule
from fyle_accounting_library.fyle_platform.enums import CacheKeyEnum
from fyle_accounting_mappings.mixins import AutoAddCreateUpdateInfoMixin

from apps.workspaces.enums import CacheKeyEnum as WorkspaceCacheKeyEnum

User = get_user_model()
Expand Down Expand Up @@ -268,6 +270,7 @@ class FeatureConfig(models.Model):
fyle_webhook_sync_enabled = models.BooleanField(default=True, help_text='Enable fyle attribute webhook sync')
migrated_to_rest_api = models.BooleanField(default=False, help_text='Migrated to using rest api')
import_billable_field_for_projects = models.BooleanField(default=False, help_text='Import billable field for projects')
import_billable_tooltip_dismissed = models.BooleanField(default=False, help_text='Import billable tooltip dismissed')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check for get_feature_config usage and imports
rg -n "get_feature_config" apps/workspaces/models.py -C 5

Repository: fylein/fyle-intacct-api

Length of output: 757


🏁 Script executed:

# Check for import_billable_tooltip_dismissed references
rg -rn "import_billable_tooltip_dismissed" apps/

Repository: fylein/fyle-intacct-api

Length of output: 287


🏁 Script executed:

# Look for cache_key_map definition
rg -n "cache_key_map" apps/workspaces/models.py -C 5

Repository: fylein/fyle-intacct-api

Length of output: 1073


🏁 Script executed:

# Check for feature flag enums
rg -n "FEATURE_CONFIG|class.*Feature" apps/workspaces/models.py -C 2

Repository: fylein/fyle-intacct-api

Length of output: 647


🏁 Script executed:

# Check if import_billable_tooltip_dismissed is accessed via get_feature_config
rg -rn "get_feature_config.*import_billable" apps/ --type py

Repository: fylein/fyle-intacct-api

Length of output: 169


🏁 Script executed:

# Check all calls to get_feature_config to see what keys are used
rg -rn "get_feature_config" apps/ --type py -A 1 | grep -E "(get_feature_config|\.get_feature_config)" | head -20

Repository: fylein/fyle-intacct-api

Length of output: 49


🏁 Script executed:

# Search more broadly for how FeatureConfig fields are accessed
rg -rn "FeatureConfig\." apps/ --type py | grep -v "migration" | head -20

Repository: fylein/fyle-intacct-api

Length of output: 2549


🏁 Script executed:

# Check the actual field name in the model - was it 'n' or full name?
cat -n apps/workspaces/models.py | sed -n '270,295p'

Repository: fylein/fyle-intacct-api

Length of output: 1905


import_billable_tooltip_dismissed missing from cache_key_map in get_feature_config method.

If this field is accessed via FeatureConfig.get_feature_config(workspace_id, 'import_billable_tooltip_dismissed'), it will raise AttributeError at line 294 when trying to call .value on None. Add the missing cache key entry to cache_key_map (lines 286–291) or handle unknown keys safely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/workspaces/models.py` at line 273, The cache_key_map in
FeatureConfig.get_feature_config is missing the
import_billable_tooltip_dismissed entry which causes AttributeError when
FeatureConfig.get_feature_config(workspace_id,
'import_billable_tooltip_dismissed') returns None and the code tries to access
.value; fix by adding the 'import_billable_tooltip_dismissed' mapping to
cache_key_map (the same pattern used for other boolean fields) or alternatively
make get_feature_config safe for unknown keys by returning a default
FeatureConfig-like object or checking for None before accessing .value in
FeatureConfig.get_feature_config.

created_at = models.DateTimeField(auto_now_add=True, help_text='Created at datetime')
updated_at = models.DateTimeField(auto_now=True, help_text='Updated at datetime')

Expand Down
11 changes: 11 additions & 0 deletions apps/workspaces/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,14 @@ class FeatureConfigSerializer(serializers.ModelSerializer):
class Meta:
model = FeatureConfig
fields = '__all__'
read_only_fields = [
'id',
'workspace',
'export_via_rabbitmq',
'import_via_rabbitmq',
'fyle_webhook_sync_enabled',
'migrated_to_rest_api',
'import_billable_field_for_projects',
'created_at',
'updated_at',
]
66 changes: 30 additions & 36 deletions apps/workspaces/views.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,60 @@
import logging
import traceback
from cryptography.fernet import Fernet
from datetime import datetime, timedelta, timezone

from cryptography.fernet import Fernet
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.db.models import Q, QuerySet
from django.contrib.auth import get_user_model

from intacctsdk import IntacctRESTSDK
from sageintacctsdk import SageIntacctSDK
from sageintacctsdk import exceptions as sage_intacct_exc
from intacctsdk.exceptions import (
BadRequestError as SageIntacctRESTBadRequestError,
InvalidTokenError as SageIntacctRestInvalidTokenError,
InternalServerError as SageIntacctRESTInternalServerError
)

from rest_framework.views import status
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework import generics, viewsets
from rest_framework.permissions import IsAuthenticated

from fyle_rest_auth.models import AuthToken
from fyle_rest_auth.utils import AuthUtils
from fyle.platform import exceptions as fyle_exc
from fyle_rest_auth.helpers import get_fyle_admin
from fyle_accounting_library.system_comments.models import SystemComment
from fyle_accounting_library.fyle_platform.enums import ExpenseImportSourceEnum
from fyle_accounting_library.system_comments.models import SystemComment
from fyle_accounting_mappings.models import ExpenseAttribute, FyleSyncTimestamp
from fyle_rest_auth.helpers import get_fyle_admin
from fyle_rest_auth.models import AuthToken
from fyle_rest_auth.utils import AuthUtils
from intacctsdk import IntacctRESTSDK
from intacctsdk.exceptions import BadRequestError as SageIntacctRESTBadRequestError
from intacctsdk.exceptions import InternalServerError as SageIntacctRESTInternalServerError
from intacctsdk.exceptions import InvalidTokenError as SageIntacctRestInvalidTokenError
from rest_framework import generics, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import status
from sageintacctsdk import SageIntacctSDK
from sageintacctsdk import exceptions as sage_intacct_exc

from apps.tasks.models import TaskLog
from apps.fyle.helpers import get_cluster_domain
from apps.fyle.models import ExpenseGroupSettings
from apps.workspaces.actions import export_to_intacct
from apps.workspaces.tasks import patch_integration_settings, sync_org_settings
from apps.sage_intacct.models import SageIntacctAttributesCount
from apps.sage_intacct.helpers import get_sage_intacct_connection
from apps.sage_intacct.enums import SageIntacctRestConnectionTypeEnum
from workers.helpers import RoutingKeyEnum, WorkerActionEnum, publish_to_rabbitmq
from fyle_intacct_api.utils import assert_valid, invalidate_sage_intacct_credentials
from apps.sage_intacct.helpers import get_sage_intacct_connection
from apps.sage_intacct.models import SageIntacctAttributesCount
from apps.tasks.models import TaskLog
from apps.workspaces.actions import export_to_intacct
from apps.workspaces.enums import SystemCommentIntentEnum, SystemCommentSourceEnum
from apps.workspaces.models import (
Workspace,
Configuration,
FeatureConfig,
FyleCredential,
IntacctSyncedTimestamp,
LastExportDetail,
SageIntacctCredential,
IntacctSyncedTimestamp,
Workspace,
)
from apps.workspaces.serializers import (
WorkspaceSerializer,
ConfigurationSerializer,
FeatureConfigSerializer,
FyleCredentialSerializer,
LastExportDetailSerializer,
SageIntacctCredentialSerializer,
WorkspaceSerializer,
)
from apps.workspaces.system_comments import add_system_comment
from apps.workspaces.enums import SystemCommentSourceEnum, SystemCommentIntentEnum

from apps.workspaces.tasks import patch_integration_settings, sync_org_settings
from fyle_intacct_api.utils import assert_valid, invalidate_sage_intacct_credentials
from workers.helpers import RoutingKeyEnum, WorkerActionEnum, publish_to_rabbitmq

User = get_user_model()
auth_utils = AuthUtils()
Expand Down Expand Up @@ -826,9 +820,9 @@ def post(self, request: Request, *args, **kwargs) -> Response:
)


class FeatureConfigView(generics.RetrieveAPIView):
class FeatureConfigView(generics.RetrieveUpdateAPIView):
"""
Get Feature Configs
Get and Update Feature Configs
"""
lookup_field = 'workspace_id'
lookup_url_kwarg = 'workspace_id'
Expand Down
37 changes: 31 additions & 6 deletions tests/sql_fixtures/reset_db_fixtures/reset_db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
--


-- Dumped from database version 15.15 (Debian 15.15-1.pgdg13+1)
-- Dumped by pg_dump version 17.7 (Debian 17.7-0+deb13u1)
-- Dumped from database version 15.12 (Debian 15.12-1.pgdg120+1)
-- Dumped by pg_dump version 17.8 (Debian 17.8-0+deb13u1)

SET statement_timeout = 0;
SET lock_timeout = 0;
Expand Down Expand Up @@ -1641,6 +1641,20 @@ ALTER SEQUENCE public.ap_payments_id_seq OWNER TO postgres;
ALTER SEQUENCE public.ap_payments_id_seq OWNED BY public.ap_payments.id;


--
-- Name: attachment_failed_count_view; Type: VIEW; Schema: public; Owner: postgres
--

CREATE VIEW public.attachment_failed_count_view AS
SELECT current_database() AS database,
count(*) AS count
FROM public.task_logs
WHERE ((task_logs.workspace_id IN ( SELECT prod_workspaces_view.id
FROM public.prod_workspaces_view)) AND ((task_logs.status)::text = 'COMPLETE'::text) AND (task_logs.updated_at > (now() - '3 days'::interval)) AND (task_logs.is_attachment_upload_failed = true));


ALTER VIEW public.attachment_failed_count_view OWNER TO postgres;

--
-- Name: auth_cache; Type: TABLE; Schema: public; Owner: postgres
--
Expand Down Expand Up @@ -3144,7 +3158,8 @@ CREATE TABLE public.feature_configs (
import_via_rabbitmq boolean NOT NULL,
fyle_webhook_sync_enabled boolean NOT NULL,
migrated_to_rest_api boolean NOT NULL,
import_billable_field_for_projects boolean NOT NULL
import_billable_field_for_projects boolean NOT NULL,
import_billable_tooltip_dismissed boolean NOT NULL
);


Expand Down Expand Up @@ -6483,6 +6498,9 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin;
281 system_comments 0001_initial 2026-01-21 08:13:46.538953+00
282 internal 0029_auto_generated_sql 2026-02-02 13:38:23.946162+00
283 tasks 0017_tasklog_stuck_export_re_attempt_count 2026-02-02 13:38:23.970584+00
284 fyle 0043_expensegroup_eg_ws_fund_null_idx 2026-03-03 08:38:07.827997+00
285 internal 0030_auto_generated_sql 2026-03-03 08:38:07.838076+00
286 workspaces 0061_featureconfig_import_billable_tooltip_dismissed 2026-03-03 08:38:07.850152+00
\.


Expand Down Expand Up @@ -9940,8 +9958,8 @@ COPY public.failed_events (id, routing_key, payload, created_at, updated_at, err
-- Data for Name: feature_configs; Type: TABLE DATA; Schema: public; Owner: postgres
--

COPY public.feature_configs (id, export_via_rabbitmq, created_at, updated_at, workspace_id, import_via_rabbitmq, fyle_webhook_sync_enabled, migrated_to_rest_api, import_billable_field_for_projects) FROM stdin;
1 f 2025-10-10 09:38:10.289737+00 2025-10-10 09:38:10.289737+00 1 f t f f
COPY public.feature_configs (id, export_via_rabbitmq, created_at, updated_at, workspace_id, import_via_rabbitmq, fyle_webhook_sync_enabled, migrated_to_rest_api, import_billable_field_for_projects, import_billable_tooltip_dismissed) FROM stdin;
1 f 2025-10-10 09:38:10.289737+00 2025-10-10 09:38:10.289737+00 1 f t f f f
\.


Expand Down Expand Up @@ -10566,7 +10584,7 @@ SELECT pg_catalog.setval('public.django_content_type_id_seq', 58, true);
-- Name: django_migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres
--

SELECT pg_catalog.setval('public.django_migrations_id_seq', 283, true);
SELECT pg_catalog.setval('public.django_migrations_id_seq', 286, true);


--
Expand Down Expand Up @@ -11930,6 +11948,13 @@ CREATE INDEX django_session_expire_date_a5c62663 ON public.django_session USING
CREATE INDEX django_session_session_key_c0390e0f_like ON public.django_session USING btree (session_key varchar_pattern_ops);


--
-- Name: eg_ws_fund_null_idx; Type: INDEX; Schema: public; Owner: postgres
--

CREATE INDEX eg_ws_fund_null_idx ON public.expense_groups USING btree (workspace_id, fund_source) WHERE (exported_at IS NULL);


--
-- Name: employee_mappings_destination_card_account_id_f030b899; Type: INDEX; Schema: public; Owner: postgres
--
Expand Down
92 changes: 85 additions & 7 deletions tests/test_workspaces/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,18 @@
from django.core.cache import cache
from django.urls import reverse
from fyle.platform import exceptions as fyle_exc
from fyle_accounting_library.system_comments.models import SystemComment
from fyle_accounting_mappings.models import MappingSetting
from fyle_rest_auth.utils import AuthUtils
from intacctsdk.exceptions import BadRequestError as SageIntacctRESTBadRequestError
from intacctsdk.exceptions import InternalServerError as SageIntacctRESTInternalServerError
from intacctsdk.exceptions import InvalidTokenError as SageIntacctRestInvalidTokenError
from sageintacctsdk import exceptions as sage_intacct_exc
from intacctsdk.exceptions import (
BadRequestError as SageIntacctRESTBadRequestError,
InvalidTokenError as SageIntacctRestInvalidTokenError,
InternalServerError as SageIntacctRESTInternalServerError
)

from apps.sage_intacct.models import SageIntacctAttributesCount
from apps.tasks.models import TaskLog
from apps.workspaces.models import Configuration, FeatureConfig, LastExportDetail, SageIntacctCredential, Workspace
from apps.workspaces.enums import SystemCommentIntentEnum, SystemCommentSourceEnum
from fyle_accounting_library.system_comments.models import SystemComment
from apps.workspaces.models import Configuration, FeatureConfig, LastExportDetail, SageIntacctCredential, Workspace
from fyle_integrations_imports.models import ImportLog
from tests.helper import dict_compare_keys
from tests.test_fyle.fixtures import data as fyle_data
Expand Down Expand Up @@ -1035,3 +1033,83 @@ def test_handle_sage_intacct_rest_api_connection_internal_server_error(mocker, a
# Reset feature config
feature_config.migrated_to_rest_api = False
feature_config.save()


def test_feature_config_view_get(api_client, test_connection):
"""
Test GET request for FeatureConfigView
"""
workspace_id = 1
url = f'/api/workspaces/{workspace_id}/feature_configs/'

api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token))

response = api_client.get(url)
assert response.status_code == 200

response_data = response.json()
assert 'id' in response_data
assert 'workspace' in response_data
assert 'import_billable_tooltip_dismissed' in response_data
assert 'export_via_rabbitmq' in response_data
assert 'import_via_rabbitmq' in response_data


def test_feature_config_view_patch(api_client, test_connection):
"""
Test PATCH request to update feature config
"""
workspace_id = 1
url = f'/api/workspaces/{workspace_id}/feature_configs/'

api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token))

feature_config = FeatureConfig.objects.get(workspace_id=workspace_id)
original_value = feature_config.import_billable_tooltip_dismissed

response = api_client.patch(
url,
data={'import_billable_tooltip_dismissed': not original_value},
format='json'
)
assert response.status_code == 200

feature_config.refresh_from_db()
assert feature_config.import_billable_tooltip_dismissed != original_value

# Reset to original value
feature_config.import_billable_tooltip_dismissed = original_value
feature_config.save()


def test_feature_config_view_patch_read_only_fields_ignored(api_client, test_connection):
"""
Test PATCH request ignores read-only fields
"""
workspace_id = 1
url = f'/api/workspaces/{workspace_id}/feature_configs/'

api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token))

feature_config = FeatureConfig.objects.get(workspace_id=workspace_id)
original_export_via_rabbitmq = feature_config.export_via_rabbitmq

response = api_client.patch(
url,
data={
'export_via_rabbitmq': not original_export_via_rabbitmq,
'import_billable_tooltip_dismissed': True
},
format='json'
)
assert response.status_code == 200

feature_config.refresh_from_db()
# export_via_rabbitmq should remain unchanged (read-only)
assert feature_config.export_via_rabbitmq == original_export_via_rabbitmq
# import_billable_tooltip_dismissed should be updated
assert feature_config.import_billable_tooltip_dismissed is True

# Reset
feature_config.import_billable_tooltip_dismissed = False
feature_config.save()
Loading